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

View File

@@ -3,7 +3,10 @@ import ms from "ms";
import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
import {
DynamicSecretDataFetchTypes,
DynamicSecretProviderSchema
} from "@app/ee/services/dynamic-secret/providers/models";
import { DYNAMIC_SECRETS } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn";
@@ -77,6 +80,32 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/fetch-provider-data",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
dataFetchType: z.string().min(1).describe("Type of data to fetch"),
provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider)
}),
response: {
200: z.object({
data: z.unknown()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const dataFetchType =
DynamicSecretDataFetchTypes[req.body.dataFetchType as keyof typeof DynamicSecretDataFetchTypes];
const data = await server.services.dynamicSecret.fetchData({ provider: req.body.provider, dataFetchType });
return data;
}
});
server.route({
method: "PATCH",
url: "/:name",

View File

@@ -17,6 +17,7 @@ import {
TCreateDynamicSecretDTO,
TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO,
TDynamicSecretsFetchDataDTO,
TListDynamicSecretsDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
@@ -332,11 +333,20 @@ export const dynamicSecretServiceFactory = ({
return dynamicSecretCfg;
};
const fetchData = async ({ provider, dataFetchType }: TDynamicSecretsFetchDataDTO) => {
const selectedProvider = dynamicSecretProviders[provider.type];
if (selectedProvider.fetchData) {
const data = selectedProvider.fetchData(provider.inputs, dataFetchType);
return data;
}
};
return {
create,
updateByName,
deleteByName,
getDetails,
list
list,
fetchData
};
};

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { TProjectPermission } from "@app/lib/types";
import { DynamicSecretProviderSchema } from "./providers/models";
import { DynamicSecretDataFetchTypes, DynamicSecretProviderSchema } from "./providers/models";
// various status for dynamic secret that happens in background
export enum DynamicSecretStatus {
@@ -52,3 +52,8 @@ export type TListDynamicSecretsDTO = {
environmentSlug: string;
projectSlug: string;
} & Omit<TProjectPermission, "projectId">;
export type TDynamicSecretsFetchDataDTO = {
provider: TProvider;
dataFetchType: DynamicSecretDataFetchTypes;
};

View File

@@ -0,0 +1,140 @@
import axios from "axios";
import { customAlphabet } from "nanoid";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { AzureEntraIDSchema, DynamicSecretDataFetchTypes, TDynamicProviderFns } from "./models";
const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 64)();
};
export const AzureEntraIDProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
return providerInputs;
};
const getToken = async (tenantId: string): Promise<{ token?: string; success: boolean }> => {
const appCfg = getConfig();
const response = await axios.post<{ access_token: string }>(
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
{
grant_type: "client_credentials",
client_id: appCfg.MSFT_ENTRA_ID_APPLICATION_ID,
client_secret: appCfg.MSFT_ENTRA_ID_CLIENT_SECRET,
scope: "https://graph.microsoft.com/.default"
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
if (response.status === 200) {
return { token: response.data.access_token, success: true };
}
return { success: false };
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
return data.success;
};
const renew = async (inputs: unknown, entityId: string) => {
// Do nothing
return { entityId };
};
const create = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const password = generatePassword();
try {
const response = await axios.patch(
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
{
passwordProfile: {
forceChangePasswordNextSignIn: false,
password
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${data.token}`
}
}
);
if (response.status !== 204) {
throw new BadRequestError({ message: "Failed to update password" });
}
} catch (error) {
logger.info(JSON.stringify(error));
}
return { entityId: providerInputs.userId, data: { userId: providerInputs.userId, password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
await create(inputs);
return { entityId };
};
const fetchData = async (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
switch (toFetch) {
case DynamicSecretDataFetchTypes.Users: {
const response = await axios.get<{ value: [{ displayName: string; id: string }] }>(
`${MSFT_GRAPH_API_URL}/users`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${data.token}`
}
}
);
const users = response.data.value.map((user: { displayName: string; id: string }) => {
return {
name: user.displayName,
id: user.id
};
});
return {
data: {
users
}
};
}
default:
throw new BadRequestError({ message: "Unknown data to fetch" });
}
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew,
fetchData
};
};

View File

@@ -1,5 +1,6 @@
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { ElasticSearchProvider } from "./elastic-search";
import { DynamicSecretProviders } from "./models";
@@ -18,5 +19,6 @@ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(),
[DynamicSecretProviders.MongoDB]: MongoDBProvider(),
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider()
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider()
});

View File

@@ -166,6 +166,11 @@ export const DynamicSecretMongoDBSchema = z.object({
)
});
export const AzureEntraIDSchema = z.object({
tenantId: z.string().trim().min(1),
userId: z.string().trim().min(1)
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -175,7 +180,12 @@ 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 DynamicSecretDataFetchTypes {
Users = "users"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -187,7 +197,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }),
z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }),
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema })
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema })
]);
export type TDynamicProviderFns = {
@@ -196,4 +207,5 @@ export type TDynamicProviderFns = {
validateProviderInputs: (inputs: object) => Promise<unknown>;
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
fetchData?: (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => Promise<{ data: unknown }>;
};

View File

@@ -134,6 +134,9 @@ const envSchema = z
LICENSE_SERVER_KEY: zpStr(z.string().optional()),
LICENSE_KEY: zpStr(z.string().optional()),
LICENSE_KEY_OFFLINE: zpStr(z.string().optional()),
// MICROSOFT ENTRA ID APP
MSFT_ENTRA_ID_APPLICATION_ID: zpStr(z.string().optional()),
MSFT_ENTRA_ID_CLIENT_SECRET: zpStr(z.string().optional()),
// GENERIC
STANDALONE_MODE: z

View File

@@ -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;

View File

@@ -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,

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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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;
};