diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 049370743e..d48aa1dfda 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -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", diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index ea08b212a3..c44b2fecde 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -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 }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts new file mode 100644 index 0000000000..e2dfe2d4b5 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -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; +} => { + 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 + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 6ae22c8697..8f2dbdc133 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -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() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index f23a60df7c..18a7b3dc9a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -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 = { diff --git a/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx new file mode 100644 index 0000000000..8a71772b18 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx @@ -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 + + + +Login to [Microsoft Entra ID](https://entra.microsoft.com/) + + + +Go to Overview, Copy and store `Tenant Id` +![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png) + + + +Go to Applications > App registrations. Click on New Registration. +![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png) + + + +Enter an application name. Click Register. + + + +Copy and store `Application Id`. +![Copy Application Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png) + + + +Go to Clients and Secrets. Click on New Client Secret. + + + +Enter a description, select expiry and click Add. + + + +Copy and store `Client Secret` value. +![Copy client Secret](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png) + + + +Go to API Permissions. Click on Add a permission. +![Click add a permission](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png) + + + +Click on Microsoft Graph. +![Click Microsoft Graph](../../../images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png) + + + +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) + + + +Click on Grant admin consent for app. Click yes to confirm. +![Grant admin consent](../../../images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png) + + + +Go to Dashboard. Click on show more. +![Show more](../../../images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png) + + + +Click on Roles & admins. Search for User Administrator and click on it. +![User Administrator](../../../images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png) + + + +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) + + + +## Set up Dynamic Secrets with Azure Entra ID + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-ad-modal.png) + + + + Prefix for the secrets to be created + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + The Tenant ID of your Azure Entra ID account. + + + + The Application ID of the application you created in Azure Entra ID. + + + + The Client Secret of the application you created in Azure Entra ID. + + + + Multi select list of users to generate secrets for. + + + + + After submitting the form, you will see a dynamic secrets for each user created in the dashboard. + + + + 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) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + 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) + + + +## 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) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png new file mode 100644 index 0000000000..561639de07 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png new file mode 100644 index 0000000000..358fc53c17 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png new file mode 100644 index 0000000000..20614f9dcc Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png new file mode 100644 index 0000000000..5c8102450a Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png new file mode 100644 index 0000000000..aa36ee39dc Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png new file mode 100644 index 0000000000..4740062db9 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png new file mode 100644 index 0000000000..481c789237 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png new file mode 100644 index 0000000000..285df9d77c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png new file mode 100644 index 0000000000..98f23c0846 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png new file mode 100644 index 0000000000..45abaa7380 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png new file mode 100644 index 0000000000..df4dc95675 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png new file mode 100644 index 0000000000..5b0cb47633 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png new file mode 100644 index 0000000000..dfc1deadd0 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png differ diff --git a/docs/mint.json b/docs/mint.json index 60f7bbbcbe..2fbce096db 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -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" ] }, { diff --git a/frontend/src/components/features/FormLabelToolTip.tsx b/frontend/src/components/features/FormLabelToolTip.tsx new file mode 100644 index 0000000000..584c47aae5 --- /dev/null +++ b/frontend/src/components/features/FormLabelToolTip.tsx @@ -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 }) => ( +
+ + {content}{" "} + + More + + + } + > + + + } + /> +
+); diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index 14382abb4d..5278feec79 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -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 }) => (
- - 1m, 2h, 3d.{" "} - - More - - - } - > - - - } + content="1m, 2h, 3d. " + linkToMore="https://github.com/vercel/ms?tab=readme-ov-file#examples" />
); diff --git a/frontend/src/hooks/api/dynamicSecret/queries.ts b/frontend/src/hooks/api/dynamicSecret/queries.ts index 481b431cc5..f84fd07124 100644 --- a/frontend/src/hooks/api/dynamicSecret/queries.ts +++ b/frontend/src/hooks/api/dynamicSecret/queries.ts @@ -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, diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 32c9b15d0e..e97234bc42 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -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; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx new file mode 100644 index 0000000000..2fb2250a07 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx @@ -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; + +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({ + 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 ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration + + +
+ + Docs + +
+
+ +
+
+
+ ( + + + + )} + + /> +
+
+
+
+ ( + + + + )} + + /> +
+
+
+
+ ( + + + + )} + + /> +
+
+
+
+ +
+ Select Users +
+
+   We create a unique dynamic secret for each user in Entra Id. +
+
+
+ ( + + + +
+ } + > +
+ + +
+ + + + {data && data.map((user) => { + const ids = value?.map((selectedUser) => selectedUser.id) + const isChecked = ids?.includes(user.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el) => el.id !== user.id) + : [...(value || []), user] + ); + }} + key={`create-policy-members-${user.id}`} + iconPos="right" + icon={isChecked && } + > + {user.name}
{`(${user.email})`} +
+ ); + })} +
+ + + )} + /> +
+
+
+
+
+ + +
+ + + ); +}; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 90eb740129..a28544a332 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -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: , provider: DynamicSecretProviders.RabbitMq, title: "RabbitMQ" + }, + { + icon: , + provider: DynamicSecretProviders.AzureEntraId, + title: "Azure Entra ID", } ]; @@ -300,6 +306,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.AzureEntraId && ( + + + + ) + } diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 85be20fc1c..c99fdda168 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -176,6 +176,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { ); } + if (provider === DynamicSecretProviders.AzureEntraId) { + const { email, password } = data as { + email: string; + password: string; + }; + + return ( +
+ + +
+ ); + } + return null; }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx new file mode 100644 index 0000000000..31ac7b5c2a --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx @@ -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; + +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({ + 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 ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+ ( + + + + )} + /> +
+
+
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 8f95bcc947..23cb458438 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -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 = ({ /> )} + + {dynamicSecretDetails?.type === DynamicSecretProviders.AzureEntraId && ( + + + + )} ); };