feat: Add dynamic secrets for Azure Entra ID

This commit is contained in:
Meet
2024-09-17 10:29:19 +05:30
parent a6f4a95821
commit b3f0d36ddc
14 changed files with 664 additions and 10 deletions
@@ -0,0 +1,112 @@
import React, { Fragment } from "react";
import { faAngleDown, faCheck, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Listbox, Transition } from "@headlessui/react";
interface ListBoxProps {
isSelected: {name:string}[];
onChange: (arg: unknown) => void;
data: {name:string}[] | null;
text?: string;
buttonAction?: () => void;
isFull?: boolean;
}
/**
* This is the component that we use for drop down lists.
* @param {object} obj
* @param {object[]} obj.isSelected - the item that is currently selected
* @param {function} obj.onChange - what happends if you select the item inside a list
* @param {object[]} obj.data - all the options available
* @param {string} obj.text - the text that shows us in front of the select option
* @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button
* @returns
*/
const ListBoxMultiple = ({
isSelected,
onChange,
data,
text,
buttonAction,
isFull
}: ListBoxProps): JSX.Element => {
return (
<Listbox value={isSelected} onChange={onChange} multiple>
<div className="relative w-full">
<Listbox.Button
className={`relative text-gray-400 ${
isFull ? "w-full" : "w-52"
} focus-visible:ring-offset-orange-300 cursor-default rounded-md bg-white/[0.07] py-2.5 pl-3 pr-10 text-left shadow-md duration-200 hover:bg-white/[0.11] focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm`}
>
<div className="flex flex-row">
{text}
<span className="ml-1 block cursor-pointer truncate font-semibold text-gray-300">
{!isSelected || isSelected.length === 0 && "None"}
{isSelected && isSelected.length > 0 && isSelected[0].name} {isSelected.length>1 && `(+${isSelected.length-1})`}
</span>
</div>
{data && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex cursor-pointer items-center pr-2">
<FontAwesomeIcon icon={faAngleDown} className="text-md mr-1.5" />
</div>
)}
</Listbox.Button>
{data && (
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="no-scrollbar::-webkit-scrollbar absolute z-[70] mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-700 bg-bunker p-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 no-scrollbar focus:outline-none sm:text-sm">
{data.map((person, personIdx) => (
<Listbox.Option
key={`${person}.${personIdx + 1}`}
className={({ active, selected }) =>
`relative my-0.5 cursor-default select-none rounded-md py-2 pl-10 pr-4 ${
selected ? "bg-white/10 font-bold text-gray-400" : ""
} ${
active && !selected
? "cursor-pointer bg-white/5 text-mineshaft-200"
: "text-gray-400"
} `
}
value={person}
>
{({ selected }) => (
<>
<span
className={`block truncate text-primary${
selected ? "font-medium" : "font-normal"
}`}
>
{person.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center rounded-lg pl-3 text-primary">
<FontAwesomeIcon icon={faCheck} className="text-md ml-1" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
{buttonAction && (
<button type="button" onClick={buttonAction} className="w-full cursor-pointer">
<div className="relative my-0.5 mt-2 flex cursor-pointer select-none justify-start rounded-md py-2 pl-10 pr-4 text-gray-400 duration-200 hover:bg-lime-300 hover:font-semibold hover:text-black">
<span className="absolute inset-y-0 left-0 flex items-center rounded-lg pl-3 pr-4">
<FontAwesomeIcon icon={faPlus} className="text-lg" />
</span>
Add Project
</div>
</button>
)}
</Listbox.Options>
</Transition>
)}
</div>
</Listbox>
);
};
export default ListBoxMultiple;
@@ -6,6 +6,7 @@ import { apiRequest } from "@app/config/request";
import {
TDetailsDynamicSecretDTO,
TDynamicSecret,
TDynamicSecretProvider,
TGetDynamicSecretsByEnvsDTO,
TListDynamicSecretDTO
} from "./types";
@@ -71,6 +72,28 @@ export const useGetDynamicSecretDetails = ({
});
};
export const useGetDynamicSecretProviderData = ({
provider,
dataFetchType
}: {
provider: TDynamicSecretProvider,
dataFetchType: "Users"
}) => {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await apiRequest.post<{ data: { users: [{ name: string, id: string }] } }>(
"/api/v1/dynamic-secrets/fetch-provider-data",
{
provider,
dataFetchType
}
);
return data.data.users;
}
});
};
export const useGetDynamicSecretsOfAllEnv = ({
path,
projectSlug,
+10 -2
View File
@@ -24,7 +24,8 @@ export enum DynamicSecretProviders {
MongoAtlas = "mongo-db-atlas",
ElasticSearch = "elastic-search",
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq"
RabbitMq = "rabbit-mq",
AzureEntraId = "azure-entra-id"
}
export enum SqlProviders {
@@ -177,7 +178,14 @@ export type TDynamicSecretProvider =
};
ca?: string;
};
};
}
| {
type: DynamicSecretProviders.AzureEntraId;
inputs: {
tenantId: string;
userId: string;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
@@ -0,0 +1,177 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import ListBoxMultiple from "@app/components/basic/ListboxMultiple";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().min(1).refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AzureEntraIdInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const [selectedUsers, setSelectedUsers] = useState([]);
const users = useGetDynamicSecretProviderData({ provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { tenantId: "<tenant id from callback>", userId: "test" } }, dataFetchType: "Users" }).data
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
selectedUsers.map(async (user: {id: string, name: string})=>{
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: "<tenant id from callback>" } },
maxTTL,
name: `${name }-${ user.name}`,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Prefix"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Select Users
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
{users &&
<ListBoxMultiple
isSelected={selectedUsers}
data={users}
onChange={setSelectedUsers}
/>
}
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,61 @@
import {
Button,
} from "@app/components/v2";
type Props = {
onCompleted: () => void;
onCancel: () => void;
};
export const AzureEntraIdSetup = ({
onCompleted,
onCancel,
}: Props) => {
return (
<div>
<div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
App Installation
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
Step 1: Click install app to install the Infisical Azure Entra ID App.
<br />
Step 2: Choose an account with admin access to Entra Id.
<br />
Step 3: Allow Infisical persmissions to read and write all users full profiles.
</div>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Role Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
Step 1: Open the Azure Entra Id dashboard.
<br />
Step 2: Go to Roles and admins {">"} User Administrator Role {">"} + Add Assignments.
<br />
Step 3: Search Infisical {">"} Click on Infisical Enterprise App {">"} Click Add.
<br />
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" onClick={onCompleted}>
{/* <Link target="_blank" href="https://login.microsoftonline.com/common/adminconsent?client_id=9805c35f-88d4-4625-9daf-66f741e4129c&redirect_uri=https://meet-dev.in.ngrok.io" content="Link" > */}
Install App
{/* </Link> */}
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
};
@@ -11,6 +11,8 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { AzureEntraIdSetup } from "./AzureEntraIdSetup";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -29,6 +31,7 @@ type Props = {
enum WizardSteps {
SelectProvider = "select-provider",
ProviderSetup = "provider-setup",
ProviderInputs = "provider-inputs"
}
@@ -77,6 +80,12 @@ const DYNAMIC_SECRET_LIST = [
icon: <SiRabbitmq size="1.5rem" />,
provider: DynamicSecretProviders.RabbitMq,
title: "RabbitMQ"
},
{
icon: <SiRabbitmq size="1.5rem" />,
provider: DynamicSecretProviders.AzureEntraId,
title: "Azure Entra ID",
hasSetupStep: true
}
];
@@ -114,7 +123,7 @@ export const CreateDynamicSecretForm = ({
>
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
<div className="flex flex-wrap items-center gap-4">
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title }) => (
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title, hasSetupStep }) => (
<div
key={`dynamic-secret-provider-${provider}`}
className="flex h-32 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
@@ -122,13 +131,22 @@ export const CreateDynamicSecretForm = ({
tabIndex={0}
onClick={() => {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
if(hasSetupStep){
setWizardStep(WizardSteps.ProviderSetup);
} else {
setWizardStep(WizardSteps.ProviderInputs);
}
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
if(hasSetupStep){
setWizardStep(WizardSteps.ProviderSetup);
} else {
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
{icon}
@@ -138,6 +156,23 @@ export const CreateDynamicSecretForm = ({
</div>
</motion.div>
)}
{
wizardStep === WizardSteps.ProviderSetup && selectedProvider === DynamicSecretProviders.AzureEntraId
&& (
<motion.div
key="dynamic-azure-entra-id-setup"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AzureEntraIdSetup
onCompleted={() => { setWizardStep(WizardSteps.ProviderInputs) }}
onCancel={handleFormReset}
/>
</motion.div>
)
}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SqlDatabase && (
<motion.div
@@ -300,6 +335,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AzureEntraId && (
<motion.div
key="dynamic-azure-entra-id-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AzureEntraIdInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)
}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -176,6 +176,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
);
}
if (provider === DynamicSecretProviders.AzureEntraId) {
const { userId, password } = data as {
userId: string;
password: string;
};
return (
<div>
<OutputDisplay label="UserId" value={userId} />
<OutputDisplay
label="Password"
value={password}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};