From 342c713805f400339c9f37b0d4d4538ed523d897 Mon Sep 17 00:00:00 2001 From: Meet Date: Wed, 18 Sep 2024 01:33:04 +0530 Subject: [PATCH] feat: Add callback and edit dynamic secret for Azure Entra ID --- .../providers/azure-entra-id.ts | 242 +++++++++--------- .../dynamic-secret/providers/models.ts | 3 +- .../src/components/basic/ListboxMultiple.tsx | 49 ++-- .../src/hooks/api/dynamicSecret/queries.ts | 17 +- frontend/src/hooks/api/dynamicSecret/types.ts | 1 + frontend/src/pages/callback/entraid.tsx | 22 ++ .../integrations/azure-entra-id/callback.tsx | 22 ++ .../AzureEntraIdInputForm.tsx | 87 ++++++- .../AzureEntraIdSetup.tsx | 14 +- .../CreateDynamicSecretLease.tsx | 6 +- .../EditDynamicSecretAzureEntraIdForm.tsx | 168 ++++++++++++ .../EditDynamicSecretForm.tsx | 19 ++ .../callback/AzureEntraIdCallbackPage.tsx | 72 ++++++ 13 files changed, 555 insertions(+), 167 deletions(-) create mode 100644 frontend/src/pages/callback/entraid.tsx create mode 100644 frontend/src/pages/integrations/azure-entra-id/callback.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx create mode 100644 frontend/src/views/callback/AzureEntraIdCallbackPage.tsx 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 index c04541c3b..27025338f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -11,130 +11,132 @@ 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)(); + 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 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 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" + } } - } ); - 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 - }; + + 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(); + + 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 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; userPrincipalName: 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; userPrincipalName: string }) => { + return { + name: user.displayName, + id: user.id, + email: user.userPrincipalName + }; + } + ); + return { + data: { + users + } + }; + } + + default: + throw new BadRequestError({ message: "Unknown data to fetch" }); + } + }; + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew, + fetchData + }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 7fb45e84a..bf336282e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -168,7 +168,8 @@ export const DynamicSecretMongoDBSchema = z.object({ export const AzureEntraIDSchema = z.object({ tenantId: z.string().trim().min(1), - userId: z.string().trim().min(1) + userId: z.string().trim().min(1), + email: z.string().trim().min(1) }); export enum DynamicSecretProviders { diff --git a/frontend/src/components/basic/ListboxMultiple.tsx b/frontend/src/components/basic/ListboxMultiple.tsx index 106f7d000..f817316c9 100644 --- a/frontend/src/components/basic/ListboxMultiple.tsx +++ b/frontend/src/components/basic/ListboxMultiple.tsx @@ -3,10 +3,15 @@ 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; +interface TextProps { + primaryText: string; + secondaryText: string; +} + +interface ListBoxProps { + isSelected: T[]; + onChange: (value: T[]) => void; + data: T[] | null; text?: string; buttonAction?: () => void; isFull?: boolean; @@ -22,27 +27,26 @@ interface ListBoxProps { * @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 = ({ +const ListBoxMultiple = ({ isSelected, onChange, data, text, buttonAction, isFull -}: ListBoxProps): JSX.Element => { +}: ListBoxProps): JSX.Element => { return (
{text} - {!isSelected || isSelected.length === 0 && "None"} - {isSelected && isSelected.length > 0 && isSelected[0].name} {isSelected.length>1 && `(+${isSelected.length-1})`} + {!isSelected || isSelected.length === 0 && "Select"} + {isSelected && isSelected.length > 0 && isSelected[0].primaryText} {isSelected.length > 1 && `(+${isSelected.length - 1})`}
{data && ( @@ -59,28 +63,25 @@ const ListBoxMultiple = ({ leaveTo="opacity-0" > - {data.map((person, personIdx) => ( + {data.map((user, personIdx) => ( - `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" + `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} + value={user} > {({ selected }) => ( <> - {person.name} + {user.primaryText} {user.secondaryText && ` (${user.secondaryText})`} {selected ? ( diff --git a/frontend/src/hooks/api/dynamicSecret/queries.ts b/frontend/src/hooks/api/dynamicSecret/queries.ts index a4c230be2..e17faf203 100644 --- a/frontend/src/hooks/api/dynamicSecret/queries.ts +++ b/frontend/src/hooks/api/dynamicSecret/queries.ts @@ -22,6 +22,12 @@ export const dynamicSecretKeys = { [{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const }; +type EntraIDUser = { + name: string; + id: string; + email: string; +}; + export const useGetDynamicSecrets = ({ projectSlug, environmentSlug, @@ -74,15 +80,17 @@ export const useGetDynamicSecretDetails = ({ export const useGetDynamicSecretProviderData = ({ provider, - dataFetchType + dataFetchType, + enabled }: { provider: TDynamicSecretProvider, - dataFetchType: "Users" + dataFetchType: "Users", + enabled: boolean }) => { return useQuery({ queryKey: ["users"], queryFn: async () => { - const { data } = await apiRequest.post<{ data: { users: [{ name: string, id: string }] } }>( + const { data } = await apiRequest.post<{ data: { users: [EntraIDUser] } }>( "/api/v1/dynamic-secrets/fetch-provider-data", { provider, @@ -90,7 +98,8 @@ export const useGetDynamicSecretProviderData = ({ } ); return data.data.users; - } + }, + enabled }); }; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index d01fa899c..3161b17f0 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -184,6 +184,7 @@ export type TDynamicSecretProvider = inputs: { tenantId: string; userId: string; + email: string; }; }; diff --git a/frontend/src/pages/callback/entraid.tsx b/frontend/src/pages/callback/entraid.tsx new file mode 100644 index 000000000..25e230a8d --- /dev/null +++ b/frontend/src/pages/callback/entraid.tsx @@ -0,0 +1,22 @@ +import Head from "next/head"; + +import { AzureEntraIdCallbackPage } from "@app/views/callback/AzureEntraIdCallbackPage"; + +const AzureEntraId = () => { + return ( + <> + + Infisical + + + + + + + + ); +}; + +export default AzureEntraId; + +AzureEntraId.requireAuth = true; diff --git a/frontend/src/pages/integrations/azure-entra-id/callback.tsx b/frontend/src/pages/integrations/azure-entra-id/callback.tsx new file mode 100644 index 000000000..25e230a8d --- /dev/null +++ b/frontend/src/pages/integrations/azure-entra-id/callback.tsx @@ -0,0 +1,22 @@ +import Head from "next/head"; + +import { AzureEntraIdCallbackPage } from "@app/views/callback/AzureEntraIdCallbackPage"; + +const AzureEntraId = () => { + return ( + <> + + Infisical + + + + + + + + ); +}; + +export default AzureEntraId; + +AzureEntraId.requireAuth = true; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx index a296ca907..ef63c0c07 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { 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"; @@ -11,12 +13,16 @@ import { Button, FormControl, Input, + Spinner, } 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({ + provider: z.object({ + tenantId: z.string().min(1), + }), defaultTTL: z.string().superRefine((val, ctx) => { const valMs = ms(val); if (valMs < 60 * 1000) @@ -49,6 +55,12 @@ type Props = { environment: string; }; +type EntraIDUser = { + name: string; + id: string; + email: string; +}; + export const AzureEntraIdInputForm = ({ onCompleted, onCancel, @@ -59,24 +71,39 @@ export const AzureEntraIdInputForm = ({ const { control, formState: { isSubmitting }, + watch, handleSubmit } = useForm({ resolver: zodResolver(formSchema) }); - - const [selectedUsers, setSelectedUsers] = useState([]); - const users = useGetDynamicSecretProviderData({ provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { tenantId: "", userId: "test" } }, dataFetchType: "Users" }).data + const tenantId = watch("provider.tenantId"); + const [selectedUsers, setSelectedUsers] = useState([]); + const { data, isLoading, isFetched, isError, isFetching } = useGetDynamicSecretProviderData({ dataFetchType: "Users", provider: {type: DynamicSecretProviders.AzureEntraId, inputs: { userId : "unused", email: "unused", tenantId }}, enabled: !!tenantId }); + const mappedUserList = data?.map((user) => ({ + primaryText: user.name, + secondaryText: user.email, + id: user.id, + })); + const handleChange = (selected: { primaryText: string; secondaryText: string; id: string }[]) => { + // Map back to EntraIDUser based on the id or other attributes + const selectedMappedUsers = data?.filter(user => + selected.some(selectedItem => selectedItem.id === user.id) + ); + if(selectedMappedUsers){ + setSelectedUsers(selectedMappedUsers); + } + }; const createDynamicSecret = useCreateDynamicSecret(); - const handleCreateDynamicSecret = async ({ name, maxTTL, defaultTTL }: TForm) => { + const handleCreateDynamicSecret = async ({ name, provider, maxTTL, defaultTTL }: TForm) => { // wait till previous request is finished if (createDynamicSecret.isLoading) return; try { - selectedUsers.map(async (user: {id: string, name: string})=>{ + selectedUsers.map(async (user: { id: string, name: string, email: string }) => { await createDynamicSecret.mutateAsync({ - provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: "" } }, + provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: provider.tenantId, email: user.email } }, maxTTL, - name: `${name }-${ user.name}`, + name: `${name}-${user.name}`, path: secretPath, defaultTTL, projectSlug, @@ -146,25 +173,61 @@ export const AzureEntraIdInputForm = ({ />
+
+
+ Configuration +
+
+
+ ( + + + + )} + + /> +
+
+
Select Users
- {users && + { + tenantId && !isError && !isFetching && isFetched && data && mappedUserList && + selectedUsers.some(selectedUser => selectedUser.id === user.id) + )} + onChange={handleChange} + data={mappedUserList} /> } + { + tenantId && isFetching && (<>

Loading

) + } + { + tenantId && !isFetching && isError && (<>

Error loading users please ensure Entra Id app is installed and tenant ID is correct

) + } + { + !tenantId && (<>

Enter tenant ID to fetch users

) + }
-
+ + + + +
+ + + ); +}; \ 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 8f95bcc94..23cb45843 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 && ( + + + + )} ); }; diff --git a/frontend/src/views/callback/AzureEntraIdCallbackPage.tsx b/frontend/src/views/callback/AzureEntraIdCallbackPage.tsx new file mode 100644 index 000000000..0692c8ecf --- /dev/null +++ b/frontend/src/views/callback/AzureEntraIdCallbackPage.tsx @@ -0,0 +1,72 @@ +import Image from "next/image" +import Link from "next/link" +import { useRouter } from "next/router"; +import { faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton } from "@app/components/v2"; + +export const AzureEntraIdCallbackPage = () => { + const router = useRouter(); + return ( +
+
+
+
+
+ + Infisical logo + +
+

+ Installed Entra Id App successfully +

+ +
+
+
+
+
+
+

+

+ Tenant ID +

{" "} +
+
+

{router.query.tenant}

+ { + if (typeof router.query.tenant === "string") { + navigator.clipboard.writeText(router.query.tenant); + createNotification({ + title: "Copied Tenant ID to clipboard succesfully", + type: "success", + text: "" + }); + } + }} + > + + +
+

+
+
+
+
+
+ ) +} \ No newline at end of file