Merge pull request #2442 from meetcshah19/meet/eng-1495-dynamic-secrets-with-ad

feat: Add dynamic secrets for Azure Entra ID
This commit is contained in:
Meet Shah
2024-09-20 23:51:45 +05:30
committed by GitHub
29 changed files with 1154 additions and 36 deletions

View File

@@ -77,6 +77,39 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/entra-id/users",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"),
applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"),
clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration")
}),
response: {
200: z
.object({
name: z.string().min(1).describe("The name of the user"),
id: z.string().min(1).describe("The ID of the user"),
email: z.string().min(1).describe("The email of the user")
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({
tenantId: req.body.tenantId,
applicationId: req.body.applicationId,
clientSecret: req.body.clientSecret
});
return data;
}
});
server.route({
method: "PATCH",
url: "/:name",

View File

@@ -20,6 +20,7 @@ import {
TListDynamicSecretsDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
import { AzureEntraIDProvider } from "./providers/azure-entra-id";
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
type TDynamicSecretServiceFactoryDep = {
@@ -332,11 +333,29 @@ export const dynamicSecretServiceFactory = ({
return dynamicSecretCfg;
};
const fetchAzureEntraIdUsers = async ({
tenantId,
applicationId,
clientSecret
}: {
tenantId: string;
applicationId: string;
clientSecret: string;
}) => {
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
tenantId,
applicationId,
clientSecret
);
return azureEntraIdUsers;
};
return {
create,
updateByName,
deleteByName,
getDetails,
list
list,
fetchAzureEntraIdUsers
};
};

View File

@@ -0,0 +1,138 @@
import axios from "axios";
import { customAlphabet } from "nanoid";
import { BadRequestError } from "@app/lib/errors";
import { AzureEntraIDSchema, 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)();
};
type User = { name: string; id: string; email: string };
export const AzureEntraIDProvider = (): TDynamicProviderFns & {
fetchAzureEntraIdUsers: (tenantId: string, applicationId: string, clientSecret: string) => Promise<User[]>;
} => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
return providerInputs;
};
const getToken = async (
tenantId: string,
applicationId: string,
clientSecret: string
): Promise<{ token?: string; success: boolean }> => {
const response = await axios.post<{ access_token: string }>(
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
{
grant_type: "client_credentials",
client_id: applicationId,
client_secret: clientSecret,
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, providerInputs.applicationId, providerInputs.clientSecret);
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, providerInputs.applicationId, providerInputs.clientSecret);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const password = generatePassword();
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" });
}
return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
// Creates a new password
await create(inputs);
return { entityId };
};
const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => {
const data = await getToken(tenantId, applicationId, clientSecret);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const response = await axios.get<{ value: [{ id: string; displayName: string; userPrincipalName: string }] }>(
`${MSFT_GRAPH_API_URL}/users`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${data.token}`
}
}
);
if (response.status !== 200) {
throw new BadRequestError({ message: "Failed to fetch users" });
}
const users = response.data.value.map((user) => {
return {
name: user.displayName,
id: user.id,
email: user.userPrincipalName
};
});
return users;
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew,
fetchAzureEntraIdUsers
};
};

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,14 @@ export const DynamicSecretMongoDBSchema = z.object({
)
});
export const AzureEntraIDSchema = z.object({
tenantId: z.string().trim().min(1),
userId: z.string().trim().min(1),
email: z.string().trim().min(1),
applicationId: z.string().trim().min(1),
clientSecret: z.string().trim().min(1)
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -175,7 +183,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 const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -187,7 +196,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 = {

View File

@@ -0,0 +1,164 @@
---
title: "Azure Entra Id"
description: "Learn how to dynamically generate Azure Entra Id user credentials."
---
The Infisical Azure Entra Id dynamic secret allows you to generate Azure Entra Id credentials on demand based on configured role.
## Prerequisites
<Steps>
<Step>
Login to [Microsoft Entra ID](https://entra.microsoft.com/)
</Step>
<Step>
Go to Overview, Copy and store `Tenant Id`
![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png)
</Step>
<Step>
Go to Applications > App registrations. Click on New Registration.
![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png)
</Step>
<Step>
Enter an application name. Click Register.
</Step>
<Step>
Copy and store `Application Id`.
![Copy Application Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png)
</Step>
<Step>
Go to Clients and Secrets. Click on New Client Secret.
</Step>
<Step>
Enter a description, select expiry and click Add.
</Step>
<Step>
Copy and store `Client Secret` value.
![Copy client Secret](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png)
</Step>
<Step>
Go to API Permissions. Click on Add a permission.
![Click add a permission](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png)
</Step>
<Step>
Click on Microsoft Graph.
![Click Microsoft Graph](../../../images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png)
</Step>
<Step>
Click on Application Permissions. Search and select `User.ReadWrite.All` and click Add permissions.
![Add User.Read.All](../../../images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png)
</Step>
<Step>
Click on Grant admin consent for app. Click yes to confirm.
![Grant admin consent](../../../images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png)
</Step>
<Step>
Go to Dashboard. Click on show more.
![Show more](../../../images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png)
</Step>
<Step>
Click on Roles & admins. Search for User Administrator and click on it.
![User Administrator](../../../images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png)
</Step>
<Step>
Click on Add assignments. Search for the application name you created and select it. Click on Add.
![Add assignments](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png)
</Step>
</Steps>
## Set up Dynamic Secrets with Azure Entra ID
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select 'Azure Entra ID'">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-ad-modal.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Prefix" type="string" required>
Prefix for the secrets to be created
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret.
</ParamField>
<ParamField path="Tenant ID" type="string" required>
The Tenant ID of your Azure Entra ID account.
</ParamField>
<ParamField path="Application ID" type="string" required>
The Application ID of the application you created in Azure Entra ID.
</ParamField>
<ParamField path="Client Secret" type="string" required>
The Client Secret of the application you created in Azure Entra ID.
</ParamField>
<ParamField path="Users" type="selection" required>
Multi select list of users to generate secrets for.
</ParamField>
</Step>
<Step title="Click `Submit`">
After submitting the form, you will see a dynamic secrets for each user created in the dashboard.
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png)
![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png)
</Step>
</Steps>
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you see the expiration time of the lease or delete a lease before it's set time to live.
![Provision Lease](/images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -167,7 +167,8 @@
"documentation/platform/dynamic-secrets/rabbit-mq",
"documentation/platform/dynamic-secrets/aws-iam",
"documentation/platform/dynamic-secrets/mongo-atlas",
"documentation/platform/dynamic-secrets/mongo-db"
"documentation/platform/dynamic-secrets/mongo-db",
"documentation/platform/dynamic-secrets/azure-entra-id"
]
},
{

View File

@@ -0,0 +1,36 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
// To give users example of possible values of TTL
export const FormLabelToolTip = ({ label, linkToMore, content }: { label: string, linkToMore: string, content: string }) => (
<div>
<FormLabel
label={label}
icon={
<Tooltip
content={
<span>
{content}{" "}
<a
href={linkToMore}
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
/>
</Tooltip>
}
/>
</div>
);

View File

@@ -1,36 +1,12 @@
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormLabel, Tooltip } from "../v2";
import { FormLabelToolTip } from "./FormLabelToolTip";
// To give users example of possible values of TTL
export const TtlFormLabel = ({ label }: { label: string }) => (
<div>
<FormLabel
<FormLabelToolTip
label={label}
icon={
<Tooltip
content={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
/>
</Tooltip>
}
content="1m, 2h, 3d. "
linkToMore="https://github.com/vercel/ms?tab=readme-ov-file#examples"
/>
</div>
);

View File

@@ -71,6 +71,34 @@ export const useGetDynamicSecretDetails = ({
});
};
export const useGetDynamicSecretProviderData = ({
tenantId,
applicationId,
clientSecret,
enabled
}: {
tenantId: string;
applicationId: string;
clientSecret: string;
enabled: boolean
}) => {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await apiRequest.post<{id:string, email: string, name:string}[]>(
"/api/v1/dynamic-secrets/entra-id/users",
{
tenantId,
applicationId,
clientSecret
}
);
return data;
},
enabled
});
};
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,17 @@ export type TDynamicSecretProvider =
};
ca?: string;
};
};
}
| {
type: DynamicSecretProviders.AzureEntraId;
inputs: {
tenantId: string;
userId: string;
email: string;
applicationId: string;
clientSecret: string;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;

View File

@@ -0,0 +1,355 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen, faCheckCircle, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input
} from "@app/components/v2";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/v2/Dropdown/Dropdown";
import { Tooltip } from "@app/components/v2/Tooltip";
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({
selectedUsers: z.array(z.object({
id: z.string().min(1),
name: z.string().min(1),
email: z.string().min(1),
})),
provider: z.object({
tenantId: z.string().min(1),
applicationId: z.string().min(1),
clientSecret: z.string().min(1)
}),
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 },
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const tenantId = watch("provider.tenantId");
const applicationId = watch("provider.applicationId");
const clientSecret = watch("provider.clientSecret");
const configurationComplete = !!(tenantId && applicationId && clientSecret);
const { data, isLoading, isError, isFetching } = useGetDynamicSecretProviderData({ tenantId, applicationId, clientSecret, enabled: !!configurationComplete });
const loading = configurationComplete && isFetching;
const errored = configurationComplete && !isFetching && isError;
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, selectedUsers, provider, maxTTL, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
selectedUsers.map(async (user: { id: string, name: string, email: string }) => {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: provider.tenantId, email: user.email, applicationId: provider.applicationId, clientSecret: provider.clientSecret } },
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">
Configuration
<Link href="https://infisical.com/docs/documentation/platform/dynamic-secrets/azure-entra-id" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Tenant Id from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.applicationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Application ID from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Client Secret from Azure Entra ID App installation" />
</FormControl>
)}
/>
</div>
</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="mb-4 flex items-center text-sm font-normal text-mineshaft-400">
&nbsp; We create a unique dynamic secret for each user in Entra Id.
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-4">
<Controller
control={control}
name="selectedUsers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu >
<DropdownMenuTrigger
className="w-72"
disabled={loading || errored || !configurationComplete}
>
<Tooltip
hidden={!loading && !errored && configurationComplete}
content=
{
<div>
{(() => {
let icon;
if (errored) {
icon = <FontAwesomeIcon icon={faWarning} color="red" />;
} else if (loading || !configurationComplete) {
icon = <FontAwesomeIcon icon={faWarning} color="yellow" />;
} else {
icon = null;
}
return icon;
})()}
<span className="ml-4 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{(() => {
let message;
if (loading) {
message = "Loading, please wait...";
} else if (errored) {
message = "Check the configuration";
} else if (!configurationComplete) {
message = "Configuration incomplete";
} else {
message = ""; // or you can leave it undefined
}
return message;
})()}
</span>
</div>
}
>
<div>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : ""}
className={`text-left ${loading || errored || !configurationComplete ? "cursor-not-allowed" : ""}`}
placeholder="Select users"
/>
</div>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="start"
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
>
{data && data.map((user) => {
const ids = value?.map((selectedUser) => selectedUser.id)
const isChecked = ids?.includes(user.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el) => el.id !== user.id)
: [...(value || []), user]
);
}}
key={`create-policy-members-${user.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.name} <br /> {`(${user.email})`}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isLoading || isError}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { DiRedis } from "react-icons/di";
import { SiApachecassandra, SiElasticsearch, SiMongodb, SiRabbitmq } from "react-icons/si";
import { SiApachecassandra, SiElasticsearch, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -11,6 +11,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -77,6 +78,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <SiRabbitmq size="1.5rem" />,
provider: DynamicSecretProviders.RabbitMq,
title: "RabbitMQ"
},
{
icon: <SiMicrosoftazure size="1.5rem" />,
provider: DynamicSecretProviders.AzureEntraId,
title: "Azure Entra ID",
}
];
@@ -300,6 +306,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 { email, password } = data as {
email: string;
password: string;
};
return (
<div>
<OutputDisplay label="Email" value={email} />
<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;
};

View File

@@ -0,0 +1,283 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
SecretInput,
} from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z.object({
email: z.string(),
userId: z.string(),
tenantId: z.string(),
applicationId: z.string(),
clientSecret: z.string()
}),
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" });
}),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretAzureEntraIdForm = ({
onClose,
dynamicSecret,
secretPath,
environment,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ maxTTL, defaultTTL, newName, inputs }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
newName: newName === dynamicSecret.name ? undefined : newName,
inputs
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
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="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.email"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Email"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.userId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isReadOnly
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isReadOnly
value={field.value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.applicationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application ID"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="inputs.clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -6,6 +6,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
@@ -202,6 +203,24 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.AzureEntraId && (
<motion.div
key="azure-entra-id-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretAzureEntraIdForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};