feat(secret-rotation): implemented frontend ui for secret rotation

This commit is contained in:
Akhil Mohan
2023-10-25 12:17:31 +05:30
parent 82e924baff
commit 97e4338335
24 changed files with 1250 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,78 @@
import { Children, cloneElement, ReactElement, ReactNode } from "react";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
export type StepperProps = {
activeStep: number;
children: ReactNode;
direction: "vertical" | "horizontal";
className?: string;
};
export const Stepper = ({ activeStep, children, direction, className }: StepperProps) => {
return (
<div
className={twMerge(
"flex items-center w-full space-x-3 p-2 border border-bunker-300/30 rounded-md",
className
)}
>
{Children.map(children as ReactNode, (child: ReactNode, index) => {
const isCompleted = activeStep > index;
const isActive = index === activeStep;
const isNotLast = index + 1 !== (children as Array<ReactNode>).length;
return (
<div
className={twMerge(
"flex items-center space-x-3 flex-shrink-0",
isNotLast && "flex-grow"
)}
>
<div className="flex items-center space-x-2 flex-shrink-0">
<div
className={twMerge(
"w-6 h-6 flex items-center justify-center font-medium text-mineshaft-800 text-sm rounded-full transition-all",
isCompleted ? "bg-primary" : "border text-bunker-300 border-primary/30",
isActive && "bg-primary text-mineshaft-800"
)}
>
{isCompleted ? <FontAwesomeIcon icon={faCheck} /> : index + 1}
</div>
{cloneElement(child as ReactElement, {
direction,
activeStep,
isCompleted,
isActive
})}
</div>
{isNotLast && (
<div
style={{ height: "1px" }}
className={twMerge("flex-grow bg-bunker-300/30", isCompleted && "bg-primary")}
/>
)}
</div>
);
})}
</div>
);
};
export type StepProps = {
title: string;
description?: ReactNode;
// isActive?: boolean;
// isCompleted?: boolean;
// activeStep?: number;
// direction?: "vertical" | "horizontal";
};
export const Step = ({ title, description }: StepProps) => {
return (
<div className="flex flex-col space-y-1 text-gray-300">
<div className="font-medium text-sm">{title}</div>
{description && <div className="text-xs">{description}</div>}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export type { StepperProps,StepProps } from "./Stepper";
export { Step,Stepper } from "./Stepper";

View File

@@ -22,6 +22,7 @@ export * from "./SecretInput";
export * from "./Select";
export * from "./Skeleton";
export * from "./Spinner";
export * from "./Stepper";
export * from "./Switch";
export * from "./Table";
export * from "./Tabs";

View File

@@ -21,7 +21,8 @@ export enum ProjectPermissionSub {
Workspace = "workspace",
Secrets = "secrets",
SecretRollback = "secret-rollback",
SecretApproval = "secret-approval"
SecretApproval = "secret-approval",
SecretRotation = "secret-rotation"
}
type SubjectFields = {
@@ -45,6 +46,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]

View File

@@ -12,6 +12,7 @@ export * from "./secretApproval";
export * from "./secretApprovalRequest";
export * from "./secretFolders";
export * from "./secretImports";
export * from "./secretRotation";
export * from "./secrets";
export * from "./secretSnapshots";
export * from "./serviceAccounts";

View File

@@ -0,0 +1,6 @@
export {
useCreateSecretRotation,
useDeleteSecretRotation,
useRestartSecretRotation
} from "./mutation";
export { useGetSecretRotationProviders, useGetSecretRotations } from "./queries";

View File

@@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretRotationKeys } from "./queries";
import {
TCreateSecretRotationDTO,
TDeleteSecretRotationDTO,
TRestartSecretRotationDTO
} from "./types";
export const useCreateSecretRotation = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretRotationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/secret-rotations", dto);
return data;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId }));
}
});
};
export const useDeleteSecretRotation = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TDeleteSecretRotationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.delete(`/api/v1/secret-rotations/${dto.id}`);
return data;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId }));
}
});
};
export const useRestartSecretRotation = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TRestartSecretRotationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/secret-rotations/restart", { id: dto.id });
return data;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId }));
}
});
};

View File

@@ -0,0 +1,110 @@
import { useCallback } from "react";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import {
decryptAssymmetric,
decryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import {
TGetSecretRotationList,
TGetSecretRotationProviders,
TSecretRotation,
TSecretRotationProviderList
} from "./types";
export const secretRotationKeys = {
listProviders: ({ workspaceId }: TGetSecretRotationProviders) => [
{ workspaceId },
"secret-rotation-providers"
],
list: ({ workspaceId }: Omit<TGetSecretRotationList, "decryptFileKey">) =>
[{ workspaceId }, "secret-rotations"] as const
};
const fetchSecretRotationProviders = async ({ workspaceId }: TGetSecretRotationProviders) => {
const { data } = await apiRequest.get<TSecretRotationProviderList>(
`/api/v1/secret-rotation-providers/${workspaceId}`
);
return data;
};
export const useGetSecretRotationProviders = ({
workspaceId,
options = {}
}: TGetSecretRotationProviders & {
options?: Omit<
UseQueryOptions<
TSecretRotationProviderList,
unknown,
TSecretRotationProviderList,
ReturnType<typeof secretRotationKeys.listProviders>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
...options,
queryKey: secretRotationKeys.listProviders({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
queryFn: async () => fetchSecretRotationProviders({ workspaceId })
});
const fetchSecretRotations = async ({
workspaceId
}: Omit<TGetSecretRotationList, "decryptFileKey">) => {
const { data } = await apiRequest.get<{ secretRotations: TSecretRotation[] }>(
"/api/v1/secret-rotations",
{ params: { workspaceId } }
);
return data.secretRotations;
};
export const useGetSecretRotations = ({
workspaceId,
decryptFileKey,
options = {}
}: TGetSecretRotationList & {
options?: Omit<
UseQueryOptions<
TSecretRotation[],
unknown,
TSecretRotation<{ key: string }>[],
ReturnType<typeof secretRotationKeys.list>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
...options,
queryKey: secretRotationKeys.list({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
queryFn: async () => fetchSecretRotations({ workspaceId }),
select: useCallback(
(data: TSecretRotation[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const decryptKey = decryptAssymmetric({
ciphertext: decryptFileKey.encryptedKey,
nonce: decryptFileKey.nonce,
publicKey: decryptFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
return data.map((el) => ({
...el,
outputs: el.outputs.map(({ key, secret }) => ({
key,
secret: {
key: decryptSymmetric({
ciphertext: secret.secretValueCiphertext,
iv: secret.secretValueIV,
tag: secret.secretValueTag,
key: decryptKey
})
}
}))
}));
},
[decryptFileKey]
)
});

View File

@@ -0,0 +1,133 @@
import { UserWsKeyPair } from "../keys/types";
import { EncryptedSecret } from "../secrets/types";
export enum TProviderFunctionTypes {
HTTP = "http",
DB = "database"
}
export enum TDbProviderClients {
// postgres, cockroack db, amazon red shift
Pg = "pg",
// mysql and maria db
Sql = "sql"
}
export enum TAssignOp {
Direct = "direct",
JmesPath = "jmesopath"
}
export type TJmesPathAssignOp = {
assign: TAssignOp.JmesPath;
path: string;
};
export type TDirectAssignOp = {
assign: TAssignOp.Direct;
value: string;
};
export type TAssignFunction = TJmesPathAssignOp | TDirectAssignOp;
export type THttpProviderFunction = {
type: TProviderFunctionTypes.HTTP;
url: string;
method: string;
header?: Record<string, string>;
query?: Record<string, string>;
body?: Record<string, unknown>;
setter?: Record<string, TAssignFunction>;
pre?: Record<string, TDirectAssignOp>;
};
export type TDbProviderFunction = {
type: TProviderFunctionTypes.DB;
client: TDbProviderClients;
username: string;
password: string;
host: string;
database: string;
port: string;
query: string;
setter?: Record<string, TAssignFunction>;
pre?: Record<string, TDirectAssignOp>;
};
export type TProviderFunction = THttpProviderFunction | TDbProviderFunction;
export type TProviderTemplate = {
inputs: {
properties: Record<string, { type: string; helperText?: string; defaultValue?: string }>;
type: "object";
required: string[];
};
outputs: Record<string, unknown>;
functions: {
set: TProviderFunction;
remove?: TProviderFunction;
test: TProviderFunction;
};
};
export type TSecretRotation<T extends unknown = EncryptedSecret> = {
_id: string;
interval: number;
provider: string;
customProvider: string;
workspace: string;
environment: string;
secretPath: string;
outputs: Array<{
key: string;
secret: T;
}>;
status?: "success" | "failed";
lastRotatedAt?: string;
statusMessage?: string;
algorithm: string;
keyEncoding: string;
};
export type TSecretRotationProvider = {
name: string;
image: string;
title: string;
description: string;
template: TProviderTemplate;
};
export type TSecretRotationProviderList = {
custom: TSecretRotationProvider[];
providers: TSecretRotationProvider[];
};
export type TGetSecretRotationProviders = {
workspaceId: string;
};
export type TGetSecretRotationList = {
workspaceId: string;
decryptFileKey: UserWsKeyPair;
};
export type TCreateSecretRotationDTO = {
workspaceId: string;
secretPath: string;
environment: string;
interval: number;
provider: string;
customProvider?: string;
inputs: Record<string, unknown>;
outputs: Record<string, string>;
};
export type TDeleteSecretRotationDTO = {
id: string;
workspaceId: string;
};
export type TRestartSecretRotationDTO = {
id: string;
workspaceId: string;
};

View File

@@ -12,6 +12,7 @@ export type SubscriptionPlan = {
secretVersioning: boolean;
slug: string;
secretApproval: string;
secretRotation: string;
tier: number;
workspaceLimit: number;
workspacesUsed: number;

View File

@@ -13,6 +13,12 @@ export type {
export { ApprovalStatus, CommitType } from "./secretApprovalRequest/types";
export type { TSecretFolder } from "./secretFolders/types";
export type { TImportedSecrets, TSecretImports } from "./secretImports/types";
export type {
TGetSecretRotationProviders,
TProviderTemplate,
TSecretRotationProvider,
TSecretRotationProviderList
} from "./secretRotation/types";
export * from "./secrets/types";
export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types";
export type { SubscriptionPlan } from "./subscriptions/types";

View File

@@ -497,6 +497,18 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?._id}/secret-rotation`} passHref>
<a className="relative">
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?._id}/secret-rotation`
}
icon="system-outline-189-domain-verification"
>
Secret rotation
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?._id}/approval`} passHref>
<a className="relative">
<MenuItem

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SecretRotationPage } from "@app/views/SecretRotationPage";
const SecretRotation = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<SecretRotationPage />
</div>
);
};
export default SecretRotation;
SecretRotation.requireAuth = true;

View File

@@ -0,0 +1,407 @@
import { useTranslation } from "react-i18next";
import {
faArrowsSpin,
faExclamationTriangle,
faFolder,
faInfoCircle,
faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
IconButton,
Modal,
ModalContent,
Skeleton,
Spinner,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr,
UpgradePlanModal
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
useDeleteSecretRotation,
useGetSecretRotationProviders,
useGetSecretRotations,
useGetUserWsKey,
useGetWorkspaceBot,
useRestartSecretRotation,
useUpdateBotActiveStatus
} from "@app/hooks/api";
import { TSecretRotationProvider } from "@app/hooks/api/types";
import { CreateRotationForm } from "./components/CreateRotationForm";
import { generateBotKey } from "./SecretRotationPage.utils";
export const SecretRotationPage = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
const permission = useProjectPermission();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"createRotation",
"activeBot",
"deleteRotation",
"upgradePlan"
] as const);
const workspaceId = currentWorkspace?._id || "";
const canCreateRotation = permission.can(
ProjectPermissionActions.Create,
ProjectPermissionSub.SecretRotation
);
const { subscription } = useSubscription();
const { data: userWsKey } = useGetUserWsKey(workspaceId);
const { data: secretRotationProviders, isLoading: isRotationProviderLoading } =
useGetSecretRotationProviders({ workspaceId });
const { data: secretRotations, isLoading: isRotationLoading } = useGetSecretRotations({
workspaceId,
decryptFileKey: userWsKey!
});
const {
mutateAsync: deleteSecretRotation,
variables: deleteSecretRotationVars,
isLoading: isDeletingRotation
} = useDeleteSecretRotation();
const {
mutateAsync: restartSecretRotation,
variables: restartSecretRotationVar,
isLoading: isRestartingRotation
} = useRestartSecretRotation();
const { data: bot } = useGetWorkspaceBot(workspaceId);
const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus();
const isBotActive = Boolean(bot?.isActive);
const handleDeleteRotation = async () => {
const { id } = popUp.deleteRotation.data as { id: string };
try {
await deleteSecretRotation({
id,
workspaceId
});
handlePopUpClose("deleteRotation");
createNotification({
type: "success",
text: "Successfully removed rotation"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to remove rotation"
});
}
};
const handleRestartRotation = async (id: string) => {
try {
await restartSecretRotation({
id,
workspaceId
});
createNotification({
type: "success",
text: "Secret rotation initiated"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to restart rotation"
});
}
};
const handleUserAcceptBotCondition = async () => {
const provider = popUp.activeBot?.data as TSecretRotationProvider;
try {
if (bot?._id) {
const botKey = generateBotKey(bot.publicKey, userWsKey!);
await updateBotActiveStatus({
isActive: true,
botId: bot._id,
workspaceId,
botKey
});
}
handlePopUpOpen("createRotation", provider);
handlePopUpClose("activeBot");
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create bot"
});
}
};
const handleCreateRotation = async (provider: TSecretRotationProvider) => {
if (subscription && !subscription?.secretRotation) {
handlePopUpOpen("upgradePlan");
return;
}
if (!canCreateRotation) {
createNotification({ type: "error", text: "Access permission denied!!" });
return;
}
if (isBotActive) {
handlePopUpOpen("createRotation", provider);
} else {
handlePopUpOpen("activeBot", provider);
}
};
return (
<div className="container mx-auto bg-bunker-800 text-white w-full h-full max-w-7xl px-6">
<div className="my-6">
<h2 className="text-3xl font-semibold text-gray-200">Secret Rotation</h2>
<p className="text-bunker-300">Auto rotate secrets for better security</p>
</div>
<div className="mb-6">
<div className="text-xl font-semibold text-gray-200 mb-2">Rotated Secrets</div>
<div className="flex flex-col space-y-2">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Secret Name</Th>
<Th>Environment</Th>
<Th>Provider</Th>
<Th>Status</Th>
<Th>Last Rotation</Th>
<Th className="text-right">Action</Th>
</Tr>
</THead>
<TBody>
{isRotationLoading && (
<TableSkeleton
innerKey="secret-rotation-loading"
columns={6}
className="bg-mineshaft-700"
/>
)}
{!isRotationLoading && secretRotations?.length === 0 && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No rotation strategy found" icon={faArrowsSpin} />
</Td>
</Tr>
)}
{secretRotations?.map(
({
environment,
secretPath,
outputs,
provider,
_id,
lastRotatedAt,
status,
statusMessage
}) => {
const isDeleting = deleteSecretRotationVars?.id === _id && isDeletingRotation;
const isRestarting =
restartSecretRotationVar?.id === _id && isRestartingRotation;
return (
<Tr key={_id}>
<Td>
{outputs
.map(({ key }) => key)
.join(",")
.toUpperCase()}
</Td>
<Td>
<div className="flex items-center border border-bunker-400 rounded p-1 px-2 w-min">
<div>{environment}</div>
<div className="flex items-center border-l border-bunker-400 pl-1 ml-1 text-xs">
<FontAwesomeIcon icon={faFolder} className="mr-1" />
{secretPath}
</div>
</div>
</Td>
<Td>{provider}</Td>
<Td>
<div className="flex items-center">
{status}
{status === "failed" && (
<Tooltip content={statusMessage}>
<FontAwesomeIcon
icon={faExclamationTriangle}
size="sm"
className="ml-2 text-red"
/>
</Tooltip>
)}
</div>
</Td>
<Td>
{lastRotatedAt
? formatDistance(new Date(lastRotatedAt), new Date())
: "-"}
</Td>
<Td>
<div className="flex space-x-2 justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretRotation}
allowedLabel="Rotate now"
renderTooltip
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
ariaLabel="delete-rotation"
isDisabled={isDeleting || !isAllowed}
onClick={() => handleRestartRotation(_id)}
>
{isRestarting ? (
<Spinner size="xs" />
) : (
<FontAwesomeIcon icon={faRotate} />
)}
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretRotation}
allowedLabel="Rotate now"
renderTooltip
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
ariaLabel="delete-rotation"
isDisabled={isDeleting || !isAllowed}
onClick={() => handlePopUpOpen("deleteRotation", { id: _id })}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<FontAwesomeIcon icon={faTrash} />
)}
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
}
)}
</TBody>
</Table>
</TableContainer>
</div>
</div>
<div className="text-xl font-semibold text-gray-200 mb-2">Infisical Rotation Providers</div>
<div className="grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4">
{isRotationProviderLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton className="h-32" key={`rotation-provider-skeleton-${index + 1}`} />
))}
{!isRotationProviderLoading &&
secretRotationProviders?.providers.map((provider) => (
<div
className="group relative cursor-pointer h-32 flex flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0}
role="button"
onKeyDown={(evt) => {
if (evt.key === "Enter") handlePopUpOpen("createRotation", provider);
}}
onClick={() => handleCreateRotation(provider)}
>
<img
src={`/images/secretRotation/${provider.image}`}
height={70}
width={70}
alt="rotation provider logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{provider.title}
</div>
<div className="group-hover:opacity-100 transition-all opacity-0 absolute top-1 right-1">
<Tooltip content={provider.description} sideOffset={10}>
<FontAwesomeIcon icon={faInfoCircle} className="text-bunker-300" />
</Tooltip>
</div>
</div>
))}
</div>
<CreateRotationForm
isOpen={popUp.createRotation.isOpen}
workspaceId={workspaceId}
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
provider={(popUp.createRotation.data as TSecretRotationProvider) || {}}
/>
<Modal
isOpen={popUp.activeBot?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("activeBot", isOpen)}
>
<ModalContent
title={t("integrations.grant-access-to-secrets") as string}
footerContent={
<div className="flex items-center space-x-2">
<Button onClick={() => handleUserAcceptBotCondition()}>
{t("integrations.grant-access-button") as string}
</Button>
<Button
onClick={() => handlePopUpClose("activeBot")}
variant="outline_bg"
colorSchema="secondary"
>
Cancel
</Button>
</div>
}
>
{t("integrations.why-infisical-needs-access")}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteRotation.isOpen}
title="Are you sure want to delete this rotation?"
subTitle="This will stop the rotation from dynamically changing. Secret won't be deleted"
onChange={(isOpen) => handlePopUpToggle("deleteRotation", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteRotation}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret rotation if you switch to Infisical's Team plan."
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretRotation }
);

View File

@@ -0,0 +1,30 @@
import { UserWsKeyPair } from "@app/hooks/api/types";
import {
decryptAssymmetric,
encryptAssymmetric
} from "../../components/utilities/cryptography/crypto";
// refactor these to common function in frontend
export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) {
throw new Error("Private Key missing");
}
const WORKSPACE_KEY = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: WORKSPACE_KEY,
publicKey: botPublicKey,
privateKey: PRIVATE_KEY
});
return { encryptedKey: ciphertext, nonce };
};

View File

@@ -0,0 +1,149 @@
import { useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent, Step, Stepper } from "@app/components/v2";
import { useCreateSecretRotation } from "@app/hooks/api";
import { TSecretRotationProvider } from "@app/hooks/api/types";
import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider";
import { GeneralDetailsForm, TFormSchema as TGeneralFormSchema } from "./steps/GeneralDetailsForm";
import { RotationInputForm } from "./steps/RotationInputForm";
import {
RotationOutputForm,
TFormSchema as TRotationOutputSchema
} from "./steps/RotationOutputForm";
const WIZARD_STEPS = [
{
title: "General"
},
{
title: "Inputs"
},
{
title: "Secret Mapping"
}
];
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
customProvider?: string;
workspaceId: string;
provider: TSecretRotationProvider;
};
export const CreateRotationForm = ({
isOpen,
onToggle,
provider,
workspaceId,
customProvider
}: Props) => {
const [wizardStep, setWizardStep] = useState(0);
const wizardData = useRef<{
general?: TGeneralFormSchema;
input?: Record<string, string>;
output?: TRotationOutputSchema;
}>({});
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretRotation } = useCreateSecretRotation();
const handleFormCancel = () => {
onToggle(false);
setWizardStep(0);
wizardData.current = {};
};
const handleFormSubmit = async () => {
if (!wizardData.current.general || !wizardData.current.input || !wizardData.current.output)
return;
try {
await createSecretRotation({
workspaceId,
provider: provider.name,
customProvider,
secretPath: wizardData.current.general.secretPath,
environment: wizardData.current.general.environment,
interval: wizardData.current.general.interval,
inputs: wizardData.current.input,
outputs: wizardData.current.output
});
setWizardStep(0);
onToggle(false);
wizardData.current = {};
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create secret rotation"
});
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(state) => {
onToggle(state);
setWizardStep(0);
wizardData.current = {};
}}
>
<ModalContent
title={`Secret rotation for ${provider.name}`}
subTitle="Provide the required inputs needed for the rotation"
className="max-w-2xl"
>
<Stepper activeStep={wizardStep} direction="horizontal" className="mb-4">
{WIZARD_STEPS.map(({ title }, index) => (
<Step title={title} key={`wizard-stepper-rotation-${index + 1}`} />
))}
</Stepper>
<AnimatePresence exitBeforeEnter>
{wizardStep === 0 && (
<motion.div
key="general-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<GeneralDetailsForm
onCancel={handleFormCancel}
onSubmit={(data) => {
wizardData.current.general = data;
setWizardStep((state) => state + 1);
}}
/>
</motion.div>
)}
{wizardStep === 1 && (
<RotationInputForm
onCancel={handleFormCancel}
onSubmit={(data) => {
wizardData.current.input = data;
setWizardStep((state) => state + 1);
}}
inputSchema={provider.template?.inputs || {}}
/>
)}
{wizardStep === 2 && (
<RotationOutputForm
environment={wizardData.current.general?.environment || ""}
secretPath={wizardData.current.general?.secretPath || "/"}
outputSchema={provider.template?.outputs || {}}
onCancel={handleFormCancel}
onSubmit={async (data) => {
wizardData.current.output = data;
await handleFormSubmit();
}}
/>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1 @@
export { CreateRotationForm } from "./CreateRotationForm";

View File

@@ -0,0 +1,93 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
const formSchema = z.object({
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
interval: z.number()
});
export type TFormSchema = z.infer<typeof formSchema>;
type Props = {
onSubmit: (data: TFormSchema) => void;
onCancel: () => void;
};
export const GeneralDetailsForm = ({ onSubmit, onCancel }: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { value, onChange } }) => (
<FormControl label="Environment">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field }) => (
<FormControl className="capitalize" label="Secret path">
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="interval"
defaultValue={15}
render={({ field }) => (
<FormControl className="capitalize" label="Rotation Interval (Days)">
<Input
{...field}
min={1}
type="number"
onChange={(evt) => field.onChange(parseInt(evt.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Next
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,61 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, SecretInput } from "@app/components/v2";
type Props = {
onSubmit: (data: Record<string, string>) => void;
onCancel: () => void;
inputSchema: {
properties: Record<string, { type: string; helperText?: string; default?: string }>;
required: string[];
};
};
const formSchema = z.record(z.string().trim().optional());
export const RotationInputForm = ({ onSubmit, onCancel, inputSchema }: Props) => {
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<Record<string, string>>({
resolver: zodResolver(formSchema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{Object.keys(inputSchema.properties || {}).map((inputName) => (
<Controller
control={control}
name={inputName}
key={`provider-input-${inputName}`}
defaultValue={inputSchema.properties[inputName]?.default}
render={({ field }) => (
<FormControl
className="capitalize"
key={`provider-input-${inputName}`}
label={inputName.replaceAll("_", " ")}
helperText={inputSchema.properties[inputName]?.helperText}
>
<SecretInput
{...field}
containerClassName="normal-case text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
required={inputSchema.required.includes(inputName)}
/>
</FormControl>
)}
/>
))}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Next
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,80 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Select, SelectItem } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api";
const formSchema = z.record(z.string());
export type TFormSchema = z.infer<typeof formSchema>;
type Props = {
environment: string;
secretPath: string;
outputSchema: Record<string, unknown>;
onSubmit: (data: TFormSchema) => void;
onCancel: () => void;
};
export const RotationOutputForm = ({
onSubmit,
onCancel,
environment,
secretPath,
outputSchema = {}
}: Props) => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
const { data: userWsKey } = useGetUserWsKey(workspaceId);
const { data: secrets } = useGetProjectSecrets({
workspaceId,
environment,
secretPath,
decryptFileKey: userWsKey!
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{Object.keys(outputSchema).map((outputName) => (
<Controller
key={`provider-output-${outputName}`}
control={control}
name={outputName}
render={({ field: { value, onChange } }) => (
<FormControl className="uppercase" label={outputName.replaceAll("_", " ")} isRequired>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
>
{secrets?.map(({ key, _id }) => (
<SelectItem value={_id} key={_id}>
{key}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
))}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Submit
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1 @@
export { SecretRotationPage } from "./SecretRotationPage";