diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts new file mode 100644 index 000000000..d8ccbc12c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts @@ -0,0 +1,19 @@ +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema, + UpdateAzureClientSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/azure-client-secret"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerAzureClientSecretRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.AzureClientSecret, + server, + responseSchema: AzureClientSecretRotationSchema, + createSchema: CreateAzureClientSecretRotationSchema, + updateSchema: UpdateAzureClientSecretRotationSchema, + generatedCredentialsSchema: AzureClientSecretRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 3dcdac30b..90edc1306 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -2,6 +2,7 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotat import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; +import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -15,6 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, - [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, - [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter + [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, + [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, + [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 772f70035..298f2c412 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -16,8 +17,9 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, - LdapPasswordRotationListItemSchema, - AwsIamUserSecretRotationListItemSchema + AzureClientSecretRotationListItemSchema, + AwsIamUserSecretRotationListItemSchema, + LdapPasswordRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts new file mode 100644 index 000000000..3e25da403 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Azure Client Secret", + type: SecretRotation.AzureClientSecret, + connection: AppConnection.AzureClientSecrets, + template: { + secretsMapping: { + clientId: "AZURE_CLIENT_ID", + clientSecret: "AZURE_CLIENT_SECRET" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts new file mode 100644 index 000000000..037df50ac --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -0,0 +1,202 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { + AzureAddPasswordResponse, + TAzureClientSecretRotationGeneratedCredentials, + TAzureClientSecretRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types"; +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets"; + +const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"; + +type AzureErrorResponse = { error: { message: string } }; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + +export const azureClientSecretRotationFactory: TRotationFactory< + TAzureClientSecretRotationWithConnection, + TAzureClientSecretRotationGeneratedCredentials +> = (secretRotation, appConnectionDAL, kmsService) => { + const { + connection, + parameters: { objectId, clientId: clientIdParam }, + secretsMapping + } = secretRotation; + + /** + * Creates a new client secret for the Azure app. + */ + const $rotateClientSecret = async () => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/addPassword`; + + const now = new Date(); + const formattedDate = `${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart( + 2, + "0" + )}-${now.getFullYear()}`; + + const endDateTime = new Date(); + endDateTime.setFullYear(now.getFullYear() + 5); + + try { + const { data } = await request.post( + endpoint, + { + passwordCredential: { + displayName: `Infisical Rotated Secret (${formattedDate})`, + endDateTime: endDateTime.toISOString() + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!data?.secretText || !data?.keyId) { + throw new Error("Invalid response from Azure: missing secretText or keyId."); + } + + return { + clientSecret: data.secretText, + keyId: data.keyId, + clientId: clientIdParam + }; + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to add client secret to Azure app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Revokes a client secret from the Azure app using its keyId. + */ + const revokeCredential = async (keyId: string) => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`; + + try { + await request.post( + endpoint, + { keyId }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to remove client secret with keyId ${keyId} from app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Issues a new set of credentials. + */ + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateClientSecret(); + return callback(credentials); + }; + + /** + * Revokes a list of credentials. + */ + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentials, + callback + ) => { + if (!credentials?.length) return callback(); + + for (const { keyId } of credentials) { + await revokeCredential(keyId); + await sleep(); + } + return callback(); + }; + + /** + * Rotates credentials by issuing new ones and revoking the old. + */ + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + oldCredentials, + callback + ) => { + const newCredentials = await $rotateClientSecret(); + if (oldCredentials?.keyId) { + await revokeCredential(oldCredentials.keyId); + } + + return callback(newCredentials); + }; + + /** + * Maps the generated credentials into the secret payload format. + */ + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({ + clientSecret + }) => [ + { key: secretsMapping.clientSecret, value: clientSecret }, + { key: secretsMapping.clientId, value: clientIdParam } + ]; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts new file mode 100644 index 000000000..9d98cac49 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AzureClientSecretRotationGeneratedCredentialsSchema = z + .object({ + clientId: z.string(), + clientSecret: z.string(), + keyId: z.string() + }) + .array() + .min(1) + .max(2); + +const AzureClientSecretRotationParametersSchema = z.object({ + objectId: z + .string() + .trim() + .min(1, "Object ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.objectId), + appName: z.string().trim().describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.appName).optional(), + clientId: z + .string() + .trim() + .min(1, "Client ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.clientId) +}); + +const AzureClientSecretRotationSecretsMappingSchema = z.object({ + clientId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientId), + clientSecret: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientSecret) +}); + +export const AzureClientSecretRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) +}); + +export const AzureClientSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.AzureClientSecret).extend({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const CreateAzureClientSecretRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const UpdateAzureClientSecretRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema.optional(), + secretsMapping: AzureClientSecretRotationSecretsMappingSchema.optional() +}); + +export const AzureClientSecretRotationListItemSchema = z.object({ + name: z.literal("Azure Client Secret"), + connection: z.literal(AppConnection.AzureClientSecrets), + type: z.literal(SecretRotation.AzureClientSecret), + template: AzureClientSecretRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts new file mode 100644 index 000000000..91f66a883 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { TAzureClientSecretsConnection } from "@app/services/app-connection/azure-client-secrets"; + +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationListItemSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema +} from "./azure-client-secret-rotation-schemas"; + +export type TAzureClientSecretRotation = z.infer; + +export type TAzureClientSecretRotationInput = z.infer; + +export type TAzureClientSecretRotationListItem = z.infer; + +export type TAzureClientSecretRotationWithConnection = TAzureClientSecretRotation & { + connection: TAzureClientSecretsConnection; +}; + +export type TAzureClientSecretRotationGeneratedCredentials = z.infer< + typeof AzureClientSecretRotationGeneratedCredentialsSchema +>; + +export interface TAzureClientSecretRotationParameters { + appId: string; + keyId?: string; + displayName?: string; +} + +export interface TAzureClientSecretRotationSecretsMapping { + appId: string; + clientSecret: string; + keyId: string; +} + +export interface AzureAddPasswordResponse { + secretText: string; + keyId: string; +} diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts new file mode 100644 index 000000000..8c741bdc6 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts @@ -0,0 +1,3 @@ +export * from "./azure-client-secret-rotation-constants"; +export * from "./azure-client-secret-rotation-schemas"; +export * from "./azure-client-secret-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index 4ddf4ee0c..d67abea2b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -2,8 +2,9 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", - LdapPassword = "ldap-password", - AwsIamUserSecret = "aws-iam-user-secret" + AzureClientSecret = "azure-client-secret", + AwsIamUserSecret = "aws-iam-user-secret", + LdapPassword = "ldap-password" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 23452d7d4..5c0d97ee8 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -5,6 +5,7 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; +import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; @@ -21,8 +22,9 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index 134aaeafc..f4ea75558 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -5,14 +5,16 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", - [SecretRotation.LdapPassword]: "LDAP Password", - [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret" + [SecretRotation.AzureClientSecret]: "Azure Client Secret", + [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", + [SecretRotation.LdapPassword]: "LDAP Password" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, - [SecretRotation.LdapPassword]: AppConnection.LDAP, - [SecretRotation.AwsIamUserSecret]: AppConnection.AWS + [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, + [SecretRotation.LdapPassword]: AppConnection.LDAP }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 70543c9b4..69743f133 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -14,6 +14,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns"; +import { azureClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns"; import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns"; import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { @@ -102,7 +103,7 @@ export type TSecretRotationV2ServiceFactoryDep = { secretQueueService: Pick; snapshotService: Pick; queueService: Pick; - appConnectionDAL: Pick; + appConnectionDAL: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -117,8 +118,9 @@ const SECRET_ROTATION_FACTORY_MAP: Record = ( secretRotation: T, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index 4d51a23c3..f6fdafe1d 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -11,6 +12,7 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 19ee7e331..37a3d3679 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1875,6 +1875,10 @@ export const AppConnections = { TEAMCITY: { instanceUrl: "The TeamCity instance URL to connect with.", accessToken: "The access token to use to connect with TeamCity." + }, + AZURE_CLIENT_SECRETS: { + code: "The OAuth code to use to connect with Azure Client Secrets.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets." } } }; @@ -2083,6 +2087,11 @@ export const SecretRotations = { AUTH0_CLIENT_SECRET: { clientId: "The client ID of the Auth0 Application to rotate the client secret for." }, + AZURE_CLIENT_SECRET: { + objectId: "The ID of the Azure Application to rotate the client secret for.", + appName: "The name of the Azure Application to rotate the client secret for.", + clientId: "The client ID of the Azure Application to rotate the client secret for." + }, LDAP_PASSWORD: { dn: "The Distinguished Name (DN) of the principal to rotate the password for." }, @@ -2113,6 +2122,10 @@ export const SecretRotations = { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, + AZURE_CLIENT_SECRET: { + clientId: "The name of the secret that the client ID will be mapped to.", + clientSecret: "The name of the secret that the rotated client secret will be mapped to." + }, LDAP_PASSWORD: { dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", password: "The name of the secret that the rotated password will be mapped to." diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 0b565f7d8..f6180b9b6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -10,6 +10,10 @@ import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema } from "@app/services/app-connection/azure-app-configuration"; +import { + AzureClientSecretsConnectionListItemSchema, + SanitizedAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema @@ -63,8 +67,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, - ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, + ...SanitizedAzureClientSecretsConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options ]); @@ -82,8 +87,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, - WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, + AzureClientSecretsConnectionListItemSchema, + WindmillConnectionListItemSchema, LdapConnectionListItemSchema, TeamCityConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts new file mode 100644 index 000000000..f699e60f1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureClientSecretsConnectionSchema, + SanitizedAzureClientSecretsConnectionSchema, + UpdateAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureClientSecretsConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureClientSecrets, + server, + sanitizedResponseSchema: SanitizedAzureClientSecretsConnectionSchema, + createSchema: CreateAzureClientSecretsConnectionSchema, + updateSchema: UpdateAzureClientSecretsConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/clients`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + clients: z.object({ name: z.string(), id: z.string(), appId: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const clients = await server.services.appConnection.azureClientSecrets.listApps(connectionId, req.permission); + + return { clients }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index c2398fd78..194f32290 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -3,6 +3,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; +import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; @@ -26,6 +27,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem(), + getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), getLdapConnectionListItem(), @@ -136,6 +142,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureClientSecrets]: + validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -157,6 +165,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: @@ -226,6 +235,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, + [AppConnection.AzureClientSecrets]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 524993b23..594c4c734 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -6,6 +6,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", + [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e872c07e4..9669de0c8 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -32,6 +32,8 @@ import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; +import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets"; +import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service"; import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; import { ValidateCamundaConnectionCredentialsSchema } from "./camunda"; import { camundaConnectionService } from "./camunda/camunda-connection-service"; @@ -76,6 +78,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + + return { + name: "Azure Client Secrets" as const, + app: AppConnection.AzureClientSecrets as const, + methods: Object.values(AzureClientSecretsConnectionMethod) as [AzureClientSecretsConnectionMethod.OAuth], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + }; +}; + +export const getAzureConnectionAccessToken = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appCfg = getConfig(); + if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureClientSecrets) { + throw new BadRequestError({ + message: `Connection with ID '${connectionId}' is not an Azure Client Secrets connection` + }); + } + + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCredentials; + + const { refreshToken } = credentials; + const currentTime = Date.now(); + + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + refresh_token: refreshToken + }) + ); + + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + + return data.access_token; +}; + +export const validateAzureClientSecretsConnectionCredentials = async (config: TAzureClientSecretsConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + + if (!SITE_URL) { + throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); + } + + if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: inputCredentials.code, + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + + switch (method) { + case AzureClientSecretsConnectionMethod.OAuth: + return { + tenantId: inputCredentials.tenantId, + accessToken: tokenResp.data.access_token, + refreshToken: tokenResp.data.refresh_token, + expiresAt: Date.now() + tokenResp.data.expires_in * 1000 + }; + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts new file mode 100644 index 000000000..2b4e65a13 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; + +export const AzureClientSecretsConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.code), + tenantId: z + .string() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) +}); + +export const AzureClientSecretsConnectionOAuthOutputCredentialsSchema = z.object({ + tenantId: z.string(), + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number() +}); + +export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.OAuth) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) + }) +]); + +export const CreateAzureClientSecretsConnectionSchema = ValidateAzureClientSecretsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets) +); + +export const UpdateAzureClientSecretsConnectionSchema = z + .object({ + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets)); + +const BaseAzureClientSecretsConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.AzureClientSecrets) +}); + +export const AzureClientSecretsConnectionSchema = z.intersection( + BaseAzureClientSecretsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema.pick({ + tenantId: true + }) + }) +]); + +export const AzureClientSecretsConnectionListItemSchema = z.object({ + name: z.literal("Azure Client Secrets"), + app: z.literal(AppConnection.AzureClientSecrets), + methods: z.nativeEnum(AzureClientSecretsConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts new file mode 100644 index 000000000..fb20fbadd --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureClientSecretsConnectionOAuthOutputCredentialsSchema, + AzureClientSecretsConnectionSchema, + CreateAzureClientSecretsConnectionSchema, + ValidateAzureClientSecretsConnectionCredentialsSchema +} from "./azure-client-secrets-connection-schemas"; + +export type TAzureClientSecretsConnection = z.infer; + +export type TAzureClientSecretsConnectionInput = z.infer & { + app: AppConnection.AzureClientSecrets; +}; + +export type TValidateAzureClientSecretsConnectionCredentialsSchema = + typeof ValidateAzureClientSecretsConnectionCredentialsSchema; + +export type TAzureClientSecretsConnectionConfig = DiscriminativePick< + TAzureClientSecretsConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TAzureClientSecretsConnectionCredentials = z.infer< + typeof AzureClientSecretsConnectionOAuthOutputCredentialsSchema +>; + +export interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + +export interface TAzureRegisteredApp { + id: string; + appId: string; + displayName: string; + description?: string; + createdDateTime: string; + identifierUris?: string[]; + signInAudience?: string; +} + +export interface TAzureListRegisteredAppsResponse { + "@odata.context": string; + "@odata.nextLink"?: string; + value: TAzureRegisteredApp[]; +} + +export interface TAzureClientSecret { + keyId: string; + displayName?: string; + startDateTime: string; + endDateTime: string; + secretText?: string; +} diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts new file mode 100644 index 000000000..336c48d58 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts @@ -0,0 +1,68 @@ +import { request } from "@app/lib/config/request"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { + TAzureClientSecretsConnection, + TAzureListRegisteredAppsResponse, + TAzureRegisteredApp +} from "./azure-client-secrets-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +const listAzureRegisteredApps = async ( + appConnection: TAzureClientSecretsConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const accessToken = await getAzureConnectionAccessToken(appConnection.id, appConnectionDAL, kmsService); + + const graphEndpoint = `https://graph.microsoft.com/v1.0/applications`; + + const apps: TAzureRegisteredApp[] = []; + let nextLink = graphEndpoint; + + while (nextLink) { + // eslint-disable-next-line no-await-in-loop + const { data: appsPage } = await request.get(nextLink, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + apps.push(...appsPage.value); + nextLink = appsPage["@odata.nextLink"] || ""; + } + + return apps; +}; + +export const azureClientSecretsConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listApps = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AzureClientSecrets, connectionId, actor); + + const apps = await listAzureRegisteredApps(appConnection, appConnectionDAL, kmsService); + + return apps.map((app) => ({ + id: app.id, + name: app.displayName, + appId: app.appId + })); + }; + + return { + listApps + }; +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/index.ts b/backend/src/services/app-connection/azure-client-secrets/index.ts new file mode 100644 index 000000000..60177973e --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-client-secrets-connection-enums"; +export * from "./azure-client-secrets-connection-fns"; +export * from "./azure-client-secrets-connection-schemas"; +export * from "./azure-client-secrets-connection-types"; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 8e8a6b2a7..116597ec4 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -38,8 +38,12 @@ export const getAzureConnectionAccessToken = async ( throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); } - if (appConnection.app !== AppConnection.AzureKeyVault && appConnection.app !== AppConnection.AzureAppConfiguration) { - throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure Key Vault connection` }); + if ( + appConnection.app !== AppConnection.AzureKeyVault && + appConnection.app !== AppConnection.AzureAppConfiguration && + appConnection.app !== AppConnection.AzureClientSecrets + ) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } const credentials = (await decryptAppConnectionCredentials({ diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx new file mode 100644 index 000000000..238f8ae18 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-client-secrets/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx new file mode 100644 index 000000000..c1c6bd6a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-client-secrets" +--- + + + Azure Client Secret Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx new file mode 100644 index 000000000..8482e2ad1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..555ae20f3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..f6c23483e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-client-secrets/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx new file mode 100644 index 000000000..795906cfd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-client-secrets" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx new file mode 100644 index 000000000..f60993285 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- + + + Azure Client Secret Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx new file mode 100644 index 000000000..eb998767b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/azure-client-secret" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx new file mode 100644 index 000000000..31e4a5697 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..db5ec7a2a --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..c1ca2d958 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..c6da0709c --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx new file mode 100644 index 000000000..da970a16f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/azure-client-secret" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx new file mode 100644 index 000000000..b824178da --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/azure-client-secret/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx new file mode 100644 index 000000000..abb00bba0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/documentation/platform/secret-rotation/azure-client-secret.mdx b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx new file mode 100644 index 000000000..046b772f6 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx @@ -0,0 +1,142 @@ +--- +title: "Azure Client Secret" +description: "Learn how to automatically rotate Azure Client Secrets." +--- + +## Prerequisites + +- Create an [Azure Client Secret Connection](/integrations/app-connections/azure-client-secrets). + +## Create an Azure Client Secret Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **Azure Client Secret** option. + ![Select Azure Client Secret](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png) + + 3. Select the **Azure Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png) + + - **Azure Connection** - the connection that will perform the rotation of the specified application's Client Secret. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Select the Azure application whose Client Secret you want to rotate. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png) + + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png) + + - **Client ID** - the name of the secret that the application Client ID will be mapped to. + - **Client Secret** - the name of the secret that the rotated Client Secret will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png) + + 8. Your **Azure Client Secret** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png) + + + To create an Azure Client Secret Rotation, make an API request to the [Create Azure + Client Secret Rotation](/api-reference/endpoints/secret-rotations/azure-client-secret/create) API endpoint. + + You will first need the **Client ID** and **Object ID** of the Azure application you want to rotate the secret for. This can be obtained from the Applications dashboard. + ![Azure Client ID](/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png) + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/azure-client-secret \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-azure-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my client secret rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "objectId": "...", + "clientId": "...", + "appName": "..." + }, + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-azure-rotation", + "description": "my client secret rotation", + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "azure", + "name": "my-azure-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "azure-client-secret", + "parameters": { + "objectId": "...", + "appName": "...", + "clientId": "..." + } + } + } + ``` + + diff --git a/docs/images/app-connections/azure/client-secrets/config-credentials-1.png b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png new file mode 100644 index 000000000..954f8aeb9 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png differ diff --git a/docs/images/app-connections/azure/client-secrets/create-oauth-method.png b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png new file mode 100644 index 000000000..e38707ea1 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png differ diff --git a/docs/images/app-connections/azure/client-secrets/oauth-connection.png b/docs/images/app-connections/azure/client-secrets/oauth-connection.png new file mode 100644 index 000000000..5bc8e479a Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/oauth-connection.png differ diff --git a/docs/images/app-connections/azure/client-secrets/select-connection.png b/docs/images/app-connections/azure/client-secrets/select-connection.png new file mode 100644 index 000000000..84091ef25 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/select-connection.png differ diff --git a/docs/images/integrations/azure-client-secrets/app-api-permissions.png b/docs/images/integrations/azure-client-secrets/app-api-permissions.png new file mode 100644 index 000000000..f64d8b50f Binary files /dev/null and b/docs/images/integrations/azure-client-secrets/app-api-permissions.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png new file mode 100644 index 000000000..7c040f758 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png new file mode 100644 index 000000000..043d51bcf Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png new file mode 100644 index 000000000..c4208d14c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png new file mode 100644 index 000000000..68c58d795 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png new file mode 100644 index 000000000..ee8a3b948 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png new file mode 100644 index 000000000..6ff3f3f00 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png new file mode 100644 index 000000000..a3917965d Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png new file mode 100644 index 000000000..fca3a4c4c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png differ diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx new file mode 100644 index 000000000..55416303a --- /dev/null +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -0,0 +1,103 @@ +--- +title: "Azure Client Secrets Connection" +description: "Learn how to configure an Azure Client Secrets Connection for Infisical." +--- + +Infisical currently only supports one method for connecting to Azure, which is OAuth. + + + Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure + and registering your instance with it. + + **Prerequisites:** + + - Set up Azure. + + + + Navigate to Azure Active Directory > App registrations to create a new application. + + + Azure Active Directory is now Microsoft Entra ID. + + ![Azure client secrets](/images/integrations/azure-app-configuration/config-aad.png) + ![Azure client secrets](/images/integrations/azure-app-configuration/config-new-app.png) + + Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/azure/oauth/callback`. + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + ![Azure client secrets](/images/app-connections/azure/register-callback.png) + + + + For the Azure Connection to work with Client Secrets, you need to assign the following permission to the application. + + #### Azure Client Secrets permissions + + Set the API permissions of the Azure application to include the following permissions: + - Microsoft Graph + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + - Azure App Configuration + - `KeyValue.Delete` (Delegated) + - `KeyValue.Read` (Delegated) + - `KeyValue.Write` (Delegated) + - Access Key Vault + - `user_impersonation` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + + Obtain the **Application (Client) ID** and **Directory (Tenant) ID** (this will be used later in the Infisical connection) in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. + + ![Azure client secrets](../../images/app-connections/azure/client-secrets/config-credentials-1.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-2.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-3.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. + + - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + + Once added, restart your Infisical instance and use the Azure Client Secrets connection. + + + + + +## Setup Azure Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/client-secrets/select-connection.png) + + + Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous step. + + Now select the **OAuth** method and click **Connect to Azure**. + + ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-oauth-method.png) + + + + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets + Authorization](/images/app-connections/azure/grant-access.png) + + + Your **Azure Client Secrets Connection** is now available for use. ![Azure Client Secrets](/images/app-connections/azure/client-secrets/oauth-connection.png) + + diff --git a/docs/mint.json b/docs/mint.json index 63eb41ddb..56c63982d 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -181,6 +181,7 @@ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", "documentation/platform/secret-rotation/postgres-credentials" @@ -430,6 +431,7 @@ "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", "integrations/app-connections/databricks", @@ -887,6 +889,19 @@ "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/azure-client-secret/create", + "api-reference/endpoints/secret-rotations/azure-client-secret/delete", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/list", + "api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/azure-client-secret/update" + ] + }, { "group": "LDAP Password", "pages": [ @@ -980,6 +995,18 @@ "api-reference/endpoints/app-connections/azure-app-configuration/delete" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/app-connections/azure-client-secret/list", + "api-reference/endpoints/app-connections/azure-client-secret/available", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-id", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-name", + "api-reference/endpoints/app-connections/azure-client-secret/create", + "api-reference/endpoints/app-connections/azure-client-secret/update", + "api-reference/endpoints/app-connections/azure-client-secret/delete" + ] + }, { "group": "Azure Key Vault", "pages": [ diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..7dd94b6c5 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx @@ -0,0 +1,38 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TAzureClientSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TAzureClientSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewAzureClientSecretRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + {activeCredentials?.clientId} + + {activeCredentials?.clientSecret} + + + } + inactiveCredentials={ + <> + {inactiveCredentials?.clientId} + + {inactiveCredentials?.clientSecret} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index d5af51eef..a8a00e17f 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials"; +import { ViewAzureClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials"; import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials"; import { Modal, ModalContent, Spinner } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; @@ -75,6 +76,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.AzureClientSecret: + Component = ( + + ); + break; case SecretRotation.LdapPassword: Component = ( { + const { control, watch, setValue } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const connectionId = watch("connection.id"); + + const { data: clients, isPending: isClientsPending } = useAzureConnectionListClients( + connectionId, + { enabled: Boolean(connectionId) } + ); + + return ( + ( + + Ensure that your connection has the{" "} + + Application.ReadWrite.All, Directory.ReadWrite.All, + Application.ReadWrite.OwnedBy, user_impersonation and User.Read + {" "} + permissions and the application exists in Azure. + + } + > +
+ Don't see the application you're looking for?{" "} + +
+ + } + > + client.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue("parameters.appName", (option as SingleValue)?.name ?? ""); + setValue("parameters.clientId", (option as SingleValue)?.appId ?? ""); + }} + options={clients} + placeholder="Select an application..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx index cdbf63111..a871eb54f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; +import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRotationParametersFields"; import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx new file mode 100644 index 000000000..9770d14a4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx @@ -0,0 +1,30 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const AzureClientSecretRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const [parameters, { clientId, clientSecret }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.appName} + {parameters.objectId} + + + {clientId} + {clientSecret} + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 23ee25d7f..2bfdc16fd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -8,6 +8,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; +import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields"; import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; @@ -15,6 +16,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..77a34d083 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx @@ -0,0 +1,58 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; + +import { SecretsMappingTable } from "./shared"; + +export const AzureClientSecretRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.AzureClientSecret); + + const items = [ + { + name: "Client ID", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientId" + /> + ) + }, + { + name: "Client Secret", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientSecret" + /> + ) + } + ]; + + return ; +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 16bffe6cf..9da51272b 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; +import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields"; import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts new file mode 100644 index 000000000..3db113003 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AzureClientSecretRotationSchema = z + .object({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: z.object({ + objectId: z.string().trim().min(1, "Object ID required"), + appName: z.string().trim().min(1, "App Name required"), + clientId: z.string().trim().min(1, "Client ID required") + }), + secretsMapping: z.object({ + clientId: z.string().trim().min(1, "Client ID required"), + clientSecret: z.string().trim().min(1, "Client Secret required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index 6dd65b0bb..b0484ae67 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -2,14 +2,16 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema"; import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; +import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; const SecretRotationUnionSchema = z.discriminatedUnion("type", [ + Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, - Auth0ClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 02c0fbbcb..35d481b82 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,6 +13,7 @@ import { Auth0ConnectionMethod, AwsConnectionMethod, AzureAppConfigurationConnectionMethod, + AzureClientSecretsConnectionMethod, AzureKeyVaultConnectionMethod, CamundaConnectionMethod, DatabricksConnectionMethod, @@ -44,6 +45,10 @@ export const APP_CONNECTION_MAP: Record< name: "Azure App Configuration", image: "Microsoft Azure.png" }, + [AppConnection.AzureClientSecrets]: { + name: "Azure Client Secrets", + image: "Microsoft Azure.png" + }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -63,6 +68,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "GitHub App", icon: faGithub }; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index 8acfaafe5..451658135 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -20,6 +20,11 @@ export const SECRET_ROTATION_MAP: Record< image: "Auth0.png", size: 35 }, + [SecretRotation.AzureClientSecret]: { + name: "Azure Client Secret", + image: "Microsoft Azure.png", + size: 65 + }, [SecretRotation.LdapPassword]: { name: "LDAP Password", image: "LDAP.png", @@ -36,6 +41,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, + [SecretRotation.AzureClientSecret]: true, [SecretRotation.LdapPassword]: false, [SecretRotation.AwsIamUserSecret]: true }; diff --git a/frontend/src/hooks/api/appConnections/azure/index.ts b/frontend/src/hooks/api/appConnections/azure/index.ts new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/index.ts @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/appConnections/azure/queries.tsx b/frontend/src/hooks/api/appConnections/azure/queries.tsx new file mode 100644 index 000000000..98d1c2d29 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TAzureClient } from "./types"; + +const azureConnectionKeys = { + all: [...appConnectionKeys.all, "azure"] as const, + listClients: (connectionId: string) => + [...azureConnectionKeys.all, "clients", connectionId] as const +}; + +export const useAzureConnectionListClients = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TAzureClient[], + unknown, + TAzureClient[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: azureConnectionKeys.listClients(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ clients: TAzureClient[] }>( + `/api/v1/app-connections/azure-client-secrets/${connectionId}/clients` + ); + + return data.clients; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/azure/types.ts b/frontend/src/hooks/api/appConnections/azure/types.ts new file mode 100644 index 000000000..29da637f0 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/types.ts @@ -0,0 +1,5 @@ +export type TAzureClient = { + name: string; + appId: string; + id: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 5b9d3fad4..daa0eb1c3 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -4,6 +4,7 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureClientSecrets = "azure-client-secrets", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 10c1076cd..9e9100d57 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -31,6 +31,11 @@ export type TAzureAppConfigurationConnectionOption = TAppConnectionOptionBase & oauthClientId?: string; }; +export type TAzureClientSecretsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureClientSecrets; + oauthClientId?: string; +}; + export type TDatabricksConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Databricks; }; @@ -81,6 +86,7 @@ export type TAppConnectionOption = | TGcpConnectionOption | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption + | TAzureClientSecretsConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption | TTerraformCloudConnectionOption @@ -98,6 +104,7 @@ export type TAppConnectionOptionMap = { [AppConnection.GCP]: TGcpConnectionOption; [AppConnection.AzureKeyVault]: TAzureKeyVaultConnectionOption; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; + [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts new file mode 100644 index 000000000..04ad167d2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AzureClientSecretsConnectionMethod { + OAuth = "oauth" +} + +export type TAzureClientSecretsConnection = TRootAppConnection & { + app: AppConnection.AzureClientSecrets; +} & { + method: AzureClientSecretsConnectionMethod.OAuth; + credentials: { + code: string; + tenantId: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2eabee1f2..d441e26e0 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,6 +3,7 @@ import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; +import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; @@ -20,6 +21,7 @@ import { TWindmillConnection } from "./windmill-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; +export * from "./azure-client-secrets-connection"; export * from "./azure-key-vault-connection"; export * from "./camunda-connection"; export * from "./databricks-connection"; @@ -40,6 +42,7 @@ export type TAppConnection = | TGcpConnection | TAzureKeyVaultConnection | TAzureAppConfigurationConnection + | TAzureClientSecretsConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -83,6 +86,7 @@ export type TAppConnectionMap = { [AppConnection.GCP]: TGcpConnection; [AppConnection.AzureKeyVault]: TAzureKeyVaultConnection; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; + [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 4ddf4ee0c..3b38c1d49 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", + AzureClientSecret = "azure-client-secret", LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret" } diff --git a/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts new file mode 100644 index 000000000..d4e06d45b --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts @@ -0,0 +1,38 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TAzureClientSecretRotation = TSecretRotationV2Base & { + type: SecretRotation.AzureClientSecret; + parameters: { + objectId: string; + appName: string; + }; + secretsMapping: { + clientId: string; + clientSecret: string; + }; +}; + +export type TAzureClientSecretRotationGeneratedCredentials = { + clientId: string; + clientSecret: string; +}; + +export type TAzureClientSecretRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.AzureClientSecret, + TAzureClientSecretRotationGeneratedCredentials + >; + +export type TAzureClientSecretRotationOption = { + name: string; + type: SecretRotation.AzureClientSecret; + connection: AppConnection.AzureClientSecrets; + template: { + secretsMapping: TAzureClientSecretRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 4119efc15..a1ef0bd15 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -9,6 +9,11 @@ import { TAwsIamUserSecretRotationGeneratedCredentialsResponse, TAwsIamUserSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; +import { + TAzureClientSecretRotation, + TAzureClientSecretRotationGeneratedCredentialsResponse, + TAzureClientSecretRotationOption +} from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; import { TLdapPasswordRotation, TLdapPasswordRotationGeneratedCredentialsResponse, @@ -30,6 +35,7 @@ export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation + | TAzureClientSecretRotation | TLdapPasswordRotation | TAwsIamUserSecretRotation ) & { @@ -39,6 +45,7 @@ export type TSecretRotationV2 = ( export type TSecretRotationV2Option = | TSqlCredentialsRotationOption | TAuth0ClientSecretRotationOption + | TAzureClientSecretRotationOption | TLdapPasswordRotationOption | TAwsIamUserSecretRotationOption; @@ -50,6 +57,7 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse + | TAzureClientSecretRotationGeneratedCredentialsResponse | TLdapPasswordRotationGeneratedCredentialsResponse | TAwsIamUserSecretRotationGeneratedCredentialsResponse; @@ -98,6 +106,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; + [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationOption; [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; }; @@ -106,6 +115,7 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; + [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index f47de43dd..fc4477813 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -12,6 +12,7 @@ import { AppConnectionHeader } from "../AppConnectionHeader"; import { Auth0ConnectionForm } from "./Auth0ConnectionForm"; import { AwsConnectionForm } from "./AwsConnectionForm"; import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm"; +import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnectionForm"; import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; @@ -87,6 +88,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.AzureClientSecrets: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: @@ -155,6 +158,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.AzureClientSecrets: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx new file mode 100644 index 000000000..9076c95ce --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx @@ -0,0 +1,169 @@ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { + AzureClientSecretsConnectionMethod, + TAzureClientSecretsConnection, + useGetAppConnectionOption +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAzureClientSecretsConnection; +}; + +const formSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AzureClientSecrets), + method: z.nativeEnum(AzureClientSecretsConnectionMethod), + tenantId: z.string().trim().min(1, "Tenant ID is required") +}); + +type FormData = z.infer; + +export const AzureClientSecretsConnectionForm = ({ appConnection }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.AzureClientSecrets); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection + ? { + ...appConnection, + tenantId: appConnection.credentials.tenantId + } + : { + app: AppConnection.AzureClientSecrets, + method: AzureClientSecretsConnectionMethod.OAuth + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + const onSubmit = (formData: FormData) => { + setIsRedirecting(true); + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "azureClientSecretsConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); + + switch (formData.method) { + case AzureClientSecretsConnectionMethod.OAuth: + window.location.assign( + `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-client-secrets` + ); + break; + default: + throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); + } + }; + + const isMissingConfig = !oauthClientId; + + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 8832d611e..ce7a1b489 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -7,9 +7,11 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { AzureAppConfigurationConnectionMethod, + AzureClientSecretsConnectionMethod, AzureKeyVaultConnectionMethod, GitHubConnectionMethod, TAzureAppConfigurationConnection, + TAzureClientSecretsConnection, TAzureKeyVaultConnection, TGitHubConnection, useCreateAppConnection, @@ -32,18 +34,26 @@ type AzureAppConfigurationFormData = BaseFormData & Pick & Pick; +type AzureClientSecretsFormData = BaseFormData & + Pick & + Pick; + type FormDataMap = { [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; [AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault }; [AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & { app: AppConnection.AzureAppConfiguration; }; + [AppConnection.AzureClientSecrets]: AzureClientSecretsFormData & { + app: AppConnection.AzureClientSecrets; + }; }; const formDataStorageFieldMap: Partial> = { [AppConnection.GitHub]: "githubConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", - [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData" + [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", + [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData" }; export const OAuthCallbackPage = () => { @@ -194,6 +204,54 @@ export const OAuthCallbackPage = () => { }; }, []); + const handleAzureClientSecrets = useCallback(async () => { + const formData = getFormData(AppConnection.AzureClientSecrets); + if (formData === null) return null; + + clearState(AppConnection.AzureClientSecrets); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + connectionId, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + name, + description, + method: AzureClientSecretsConnectionMethod.OAuth, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } + } catch (err: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} Azure Client Secrets Connection`, + text: err?.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + const handleGithub = useCallback(async () => { const formData = getFormData(AppConnection.GitHub); if (formData === null) return null; @@ -280,6 +338,8 @@ export const OAuthCallbackPage = () => { data = await handleAzureKeyVault(); } else if (appConnection === AppConnection.AzureAppConfiguration) { data = await handleAzureAppConfiguration(); + } else if (appConnection === AppConnection.AzureClientSecrets) { + data = await handleAzureClientSecrets(); } if (data) {