diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3e5947956..8cebbdebd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -849,7 +849,8 @@ export const registerRoutes = async ( secretVersionTagDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + appConnectionDAL }); const secretQueueService = secretQueueFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 41a87feb5..a1cc6ff5c 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -8,6 +8,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { TAppConnection, TAppConnectionInput } from "@app/services/app-connection/app-connection-types"; +import { AzureResources } from "@app/services/app-connection/azure"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerAppConnectionEndpoints = ({ @@ -73,7 +74,14 @@ export const registerAppConnectionEndpoints = { diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-connection-router.ts new file mode 100644 index 000000000..35bef681b --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-connection-router.ts @@ -0,0 +1,20 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureConnectionSchema, + SanitizedAzureConnectionSchema, + UpdateAzureConnectionSchema +} from "@app/services/app-connection/azure"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Azure, + server, + sanitizedResponseSchema: SanitizedAzureConnectionSchema, + createSchema: CreateAzureConnectionSchema, + updateSchema: UpdateAzureConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use +}; 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 4551a0fbb..46cbca36c 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,6 +1,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { registerAwsConnectionRouter } from "./aws-connection-router"; +import { registerAzureConnectionRouter } from "./azure-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; @@ -10,5 +11,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.AzureAppConfiguration, + server, + responseSchema: AzureAppConfigurationSyncSchema, + createSchema: CreateAzureAppConfigurationSyncSchema, + updateSchema: UpdateAzureAppConfigurationSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/azure-key-vault-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/azure-key-vault-sync-router.ts new file mode 100644 index 000000000..a33c513c8 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/azure-key-vault-sync-router.ts @@ -0,0 +1,17 @@ +import { + AzureKeyVaultSyncSchema, + CreateAzureKeyVaultSyncSchema, + UpdateAzureKeyVaultSyncSchema +} from "@app/services/secret-sync/azure-key-vault"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerAzureKeyVaultSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.AzureKeyVault, + server, + responseSchema: AzureKeyVaultSyncSchema, + createSchema: CreateAzureKeyVaultSyncSchema, + updateSchema: UpdateAzureKeyVaultSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 20573719b..1b9592c7c 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -2,6 +2,8 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; +import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; +import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; @@ -11,5 +13,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 61787b47c..e28ba0fa1 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -1,7 +1,8 @@ export enum AppConnection { GitHub = "github", AWS = "aws", - GCP = "gcp" + GCP = "gcp", + Azure = "azure" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index d4c9f97ad..879a928a9 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -20,10 +20,15 @@ import { } from "@app/services/app-connection/github"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { AzureConnectionMethod, getAzureConnectionListItem, validateAzureConnectionCredentials } from "./azure"; + export const listAppConnectionOptions = () => { - return [getAwsAppConnectionListItem(), getGitHubConnectionListItem(), getGcpAppConnectionListItem()].sort((a, b) => - a.name.localeCompare(b.name) - ); + return [ + getAwsAppConnectionListItem(), + getGitHubConnectionListItem(), + getGcpAppConnectionListItem(), + getAzureConnectionListItem() + ].sort((a, b) => a.name.localeCompare(b.name)); }; export const encryptAppConnectionCredentials = async ({ @@ -79,6 +84,8 @@ export const validateAppConnectionCredentials = async ( return validateGitHubConnectionCredentials(appConnection); case AppConnection.GCP: return validateGcpConnectionCredentials(appConnection); + case AppConnection.Azure: + return validateAzureConnectionCredentials(appConnection); default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection ${app}`); @@ -89,6 +96,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => switch (method) { case GitHubConnectionMethod.App: return "GitHub App"; + case AzureConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index abff5cf3b..de1a78244 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -3,5 +3,6 @@ import { AppConnection } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", [AppConnection.GitHub]: "GitHub", - [AppConnection.GCP]: "GCP" + [AppConnection.GCP]: "GCP", + [AppConnection.Azure]: "Azure" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 8beaec21d..fbcae3f42 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -27,7 +27,9 @@ import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-con import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; import { TAppConnectionDALFactory } from "./app-connection-dal"; +import { AzureResources } from "./azure"; import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; @@ -42,7 +44,8 @@ export type TAppConnectionServiceFactory = ReturnType = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, - [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema + [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, + [AppConnection.Azure]: ValidateGcpConnectionCredentialsSchema }; export const appConnectionServiceFactory = ({ @@ -347,7 +350,27 @@ export const appConnectionServiceFactory = ({ ) ); - return availableConnections as Omit[]; + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actor.orgId + }); + + const decryptedConnections = availableConnections.map((connection) => { + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: connection.encryptedCredentials + }); + + const credentials = JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; + + return { + ...connection, + ...(app === AppConnection.Azure && { + azureResource: (credentials as { resource: AzureResources }).resource + }) + }; + }); + + return decryptedConnections as Omit[]; }; return { diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index dfe2d1c64..d6e168467 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -11,11 +11,22 @@ import { TValidateGitHubConnectionCredentials } from "@app/services/app-connection/github"; +import { + TAzureConnection, + TAzureConnectionConfig, + TAzureConnectionInput, + TValidateAzureConnectionCredentials +} from "./azure"; import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; -export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection | TGcpConnection); +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection | TGcpConnection | TAzureConnection); -export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput | TGcpConnectionInput); +export type TAppConnectionInput = { id: string } & ( + | TAwsConnectionInput + | TGitHubConnectionInput + | TGcpConnectionInput + | TAzureConnectionInput +); export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -26,9 +37,14 @@ export type TUpdateAppConnectionDTO = Partial = { + [AzureResources.AppConfiguration]: "https://azconfig.io/.default", + [AzureResources.KeyVault]: "https://vault.azure.net/.default" +}; + +export const getAzureConnectionAccessToken = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appCfg = getConfig(); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.Azure) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure connection` }); + } + + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureConnectionCredentials; + + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access`, + client_id: appCfg.CLIENT_ID_AZURE!, + client_secret: appCfg.CLIENT_SECRET_AZURE!, + refresh_token: credentials.refreshToken + }) + ); + + const accessExpiresAt = new Date(); + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: accessExpiresAt.getTime(), + refreshToken: data.refresh_token + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.update( + { id: connectionId }, + { + encryptedCredentials + } + ); + + return { + accessToken: data.access_token + }; +}; + +export const getAzureConnectionListItem = () => { + const { CLIENT_ID_AZURE } = getConfig(); + + return { + name: "Azure" as const, + app: AppConnection.Azure as const, + methods: Object.values(AzureConnectionMethod) as [AzureConnectionMethod.OAuth], + oauthClientId: CLIENT_ID_AZURE + }; +}; + +type ExchangeCodeAzureResponse = { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +}; + +export const validateAzureConnectionCredentials = async (config: TAzureConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + const { CLIENT_ID_AZURE, CLIENT_SECRET_AZURE } = getConfig(); + + if (!CLIENT_ID_AZURE || !CLIENT_SECRET_AZURE) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_AZURE || !appCfg.CLIENT_SECRET_AZURE) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + 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 ${resourceScopes[inputCredentials.resource]}`, + client_id: appCfg.CLIENT_ID_AZURE, + client_secret: appCfg.CLIENT_SECRET_AZURE, + redirect_uri: `${appCfg.SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + // TODO(daniel): handle token refreshing + } 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 AzureConnectionMethod.OAuth: + return { + accessToken: tokenResp.data.access_token, + refreshToken: tokenResp.data.refresh_token, + expiresAt: Date.now() + tokenResp.data.expires_in * 1000, + resource: inputCredentials.resource + }; + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${method as AzureConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure/azure-connection-schemas.ts b/backend/src/services/app-connection/azure/azure-connection-schemas.ts new file mode 100644 index 000000000..4c1e62d74 --- /dev/null +++ b/backend/src/services/app-connection/azure/azure-connection-schemas.ts @@ -0,0 +1,74 @@ +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 { AzureConnectionMethod, AzureResources } from "./azure-connection-enums"; + +export const AzureConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required"), + tenantId: z.string().trim().optional(), + resource: z.nativeEnum(AzureResources) +}); + +export const AzureConnectionOAuthOutputCredentialsSchema = z.object({ + tenantId: z.string().optional(), + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), // unix timestamp, + resource: z.nativeEnum(AzureResources) +}); + +export const ValidateAzureConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.Azure).method), + credentials: AzureConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Azure).credentials + ) + }) +]); + +export const CreateAzureConnectionSchema = ValidateAzureConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Azure) +); + +export const UpdateAzureConnectionSchema = z + .object({ + credentials: AzureConnectionOAuthInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Azure).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Azure)); + +const BaseAzureConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Azure) }); + +export const AzureConnectionSchema = z.intersection( + BaseAzureConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureConnectionMethod.OAuth), + credentials: AzureConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedAzureConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureConnectionSchema.extend({ + method: z.literal(AzureConnectionMethod.OAuth), + credentials: AzureConnectionOAuthOutputCredentialsSchema.pick({ + resource: true + }) + }) +]); + +export const AzureConnectionListItemSchema = z.object({ + name: z.literal("Azure"), + app: z.literal(AppConnection.Azure), + methods: z.nativeEnum(AzureConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/azure/azure-connection-types.ts b/backend/src/services/app-connection/azure/azure-connection-types.ts new file mode 100644 index 000000000..167b327b6 --- /dev/null +++ b/backend/src/services/app-connection/azure/azure-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureConnectionOAuthOutputCredentialsSchema, + AzureConnectionSchema, + CreateAzureConnectionSchema, + ValidateAzureConnectionCredentialsSchema +} from "./azure-connection-schemas"; + +export type TAzureConnection = z.infer; + +export type TAzureConnectionInput = z.infer & { + app: AppConnection.Azure; +}; + +export type TValidateAzureConnectionCredentials = typeof ValidateAzureConnectionCredentialsSchema; + +export type TAzureConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type ExchangeCodeAzureResponse = { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +}; + +export type TAzureConnectionCredentials = z.infer; diff --git a/backend/src/services/app-connection/azure/index.ts b/backend/src/services/app-connection/azure/index.ts new file mode 100644 index 000000000..f3ae01dd0 --- /dev/null +++ b/backend/src/services/app-connection/azure/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-connection-enums"; +export * from "./azure-connection-fns"; +export * from "./azure-connection-schemas"; +export * from "./azure-connection-types"; diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-constants.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-constants.ts new file mode 100644 index 000000000..da4e335ae --- /dev/null +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Azure App Configuration", + destination: SecretSync.AzureAppConfiguration, + connection: AppConnection.Azure, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts new file mode 100644 index 000000000..848f11710 --- /dev/null +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -0,0 +1,195 @@ +/* eslint-disable no-await-in-loop */ +import https from "https"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure"; +import { isAzureKeyVaultReference } from "@app/services/integration-auth/integration-sync-secret-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; + +type TAzureAppConfigurationSecretSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +interface AzureAppConfigKeyValue { + key: string; + value: string; +} + +export const azureAppConfigurationSecretSyncFactory = ({ + kmsService, + appConnectionDAL +}: TAzureAppConfigurationSecretSyncFactoryDeps) => { + const $getCompleteAzureAppConfigValues = async (accessToken: string, baseURL: string, url: string) => { + let result: AzureAppConfigKeyValue[] = []; + let currentUrl = url; + + while (currentUrl) { + const res = await request.get<{ items: AzureAppConfigKeyValue[]; ["@nextLink"]: string }>(currentUrl, { + baseURL, + headers: { + Authorization: `Bearer ${accessToken}` + }, + // we force IPV4 because docker setup fails with ipv6 + httpsAgent: new https.Agent({ + family: 4 + }) + }); + + result = result.concat(res.data.items); + currentUrl = res.data?.["@nextLink"]; + } + + return result; + }; + + const $deleteAzureSecret = async (accessToken: string, configurationUrl: string, key: string, label?: string) => { + await request.delete(`${configurationUrl}/kv/${key}?api-version=2023-11-01`, { + headers: { + Authorization: `Bearer ${accessToken}` + }, + ...(label && + label.length > 0 && { + params: { + label + } + }), + httpsAgent: new https.Agent({ + family: 4 + }) + }); + }; + + const syncSecrets = async (secretSync: TAzureAppConfigurationSyncWithCredentials, secretMap: TSecretMap) => { + if (!secretSync.destinationConfig.configurationUrl.endsWith(".azconfig.io")) { + throw new BadRequestError({ + message: "Invalid Azure App Configuration URL provided." + }); + } + + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connectionId, appConnectionDAL, kmsService); + + const azureAppConfigValuesUrl = `/kv?api-version=2023-11-01${ + secretSync.destinationConfig.label ? `&label=${secretSync.destinationConfig.label}` : "&label=%00" + }`; + + const azureAppConfigSecrets = Object.fromEntries( + ( + await $getCompleteAzureAppConfigValues( + accessToken, + secretSync.destinationConfig.configurationUrl, + azureAppConfigValuesUrl + ) + ).map((entry) => [entry.key, entry.value]) + ); + + // add the secrets to azure app config, that are in infisical + for await (const key of Object.keys(secretMap)) { + if (!(key in azureAppConfigSecrets) || secretMap[key]?.value !== azureAppConfigSecrets[key]) { + await request.put( + `${secretSync.destinationConfig.configurationUrl}/kv/${key}?api-version=2023-11-01`, + { + value: secretMap[key]?.value, + ...(isAzureKeyVaultReference(secretMap[key]?.value || "") && { + content_type: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" + }) + }, + { + ...(secretSync.destinationConfig.label && { + params: { + label: secretSync.destinationConfig.label + } + }), + + headers: { + Authorization: `Bearer ${accessToken}` + }, + httpsAgent: new https.Agent({ + family: 4 + }) + } + ); + } + } + + // delete the secrets that are in azure app config, but not in infisical + for await (const key of Object.keys(azureAppConfigSecrets)) { + if (!(key in secretMap) || secretMap[key] === null) { + await $deleteAzureSecret( + accessToken, + secretSync.destinationConfig.configurationUrl, + key, + secretSync.destinationConfig.label + ); + } + } + }; + + const removeSecrets = async (secretSync: TAzureAppConfigurationSyncWithCredentials, secretMap: TSecretMap) => { + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connectionId, appConnectionDAL, kmsService); + + const azureAppConfigValuesUrl = `/kv?api-version=2023-11-01${ + secretSync.destinationConfig.label ? `&label=${secretSync.destinationConfig.label}` : "&label=%00" + }`; + + const azureAppConfigSecrets = Object.fromEntries( + ( + await $getCompleteAzureAppConfigValues( + accessToken, + secretSync.destinationConfig.configurationUrl, + azureAppConfigValuesUrl + ) + ).map((entry) => [entry.key, entry.value]) + ); + + for await (const infisicalKey of Object.keys(secretMap)) { + if (infisicalKey in azureAppConfigSecrets) { + await $deleteAzureSecret( + accessToken, + secretSync.destinationConfig.configurationUrl, + infisicalKey, + secretSync.destinationConfig.label + ); + } + } + }; + + const getSecrets = async (secretSync: TAzureAppConfigurationSyncWithCredentials) => { + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connectionId, appConnectionDAL, kmsService); + + const secretMap: TSecretMap = {}; + + const azureAppConfigValuesUrl = `/kv?api-version=2023-11-01${ + secretSync.destinationConfig.label ? `&label=${secretSync.destinationConfig.label}` : "&label=%00" + }`; + + const azureAppConfigSecrets = Object.fromEntries( + ( + await $getCompleteAzureAppConfigValues( + accessToken, + secretSync.destinationConfig.configurationUrl, + azureAppConfigValuesUrl + ) + ).map((entry) => [entry.key, entry.value]) + ); + + Object.keys(azureAppConfigSecrets).forEach((key) => { + secretMap[key] = { + value: azureAppConfigSecrets[key] + }; + }); + + return secretMap; + }; + + return { + syncSecrets, + removeSecrets, + getSecrets + }; +}; diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts new file mode 100644 index 000000000..a0b34f2dc --- /dev/null +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const AzureAppConfigurationSyncDestinationConfigSchema = z.object({ + configurationUrl: z.string().min(1, "App Configuration URL required"), + label: z.string().optional() +}); + +const AzureAppConfigurationSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const AzureAppConfigurationSyncSchema = BaseSecretSyncSchema( + SecretSync.AzureAppConfiguration, + AzureAppConfigurationSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.AzureAppConfiguration), + destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema +}); + +export const CreateAzureAppConfigurationSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.AzureAppConfiguration, + AzureAppConfigurationSyncOptionsConfig +).extend({ + destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema +}); + +export const UpdateAzureAppConfigurationSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.AzureAppConfiguration, + AzureAppConfigurationSyncOptionsConfig +).extend({ + destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema.optional() +}); + +export const AzureAppConfigurationSyncListItemSchema = z.object({ + name: z.literal("Azure App Configuration"), + connection: z.literal(AppConnection.Azure), + destination: z.literal(SecretSync.AzureAppConfiguration), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-types.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-types.ts new file mode 100644 index 000000000..a2d001bd4 --- /dev/null +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TAzureConnection } from "@app/services/app-connection/azure"; + +import { + AzureAppConfigurationSyncListItemSchema, + AzureAppConfigurationSyncSchema, + CreateAzureAppConfigurationSyncSchema +} from "./azure-app-configuration-sync-schemas"; + +export type TAzureAppConfigurationSync = z.infer; + +export type TAzureAppConfigurationSyncInput = z.infer; + +export type TAzureAppConfigurationSyncListItem = z.infer; + +export type TAzureAppConfigurationSyncWithCredentials = TAzureAppConfigurationSync & { + connection: TAzureConnection; +}; diff --git a/backend/src/services/secret-sync/azure-app-configuration/index.ts b/backend/src/services/secret-sync/azure-app-configuration/index.ts new file mode 100644 index 000000000..0ed052ff2 --- /dev/null +++ b/backend/src/services/secret-sync/azure-app-configuration/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-app-configuration-sync-constants"; +export * from "./azure-app-configuration-sync-fns"; +export * from "./azure-app-configuration-sync-schemas"; +export * from "./azure-app-configuration-sync-types"; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-constants.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-constants.ts new file mode 100644 index 000000000..0b9f863da --- /dev/null +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const AZURE_KEY_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Azure Key Vault", + destination: SecretSync.AzureKeyVault, + connection: AppConnection.Azure, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts new file mode 100644 index 000000000..1cb4f39f0 --- /dev/null +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -0,0 +1,246 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types"; + +type TAzureKeyVaultSecretSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +export const azureKeyVaultSecretSyncFactory = ({ + kmsService, + appConnectionDAL +}: TAzureKeyVaultSecretSyncFactoryDeps) => { + const $getAzureKeyVaultSecrets = async (accessToken: string, vaultBaseUrl: string) => { + const paginateAzureKeyVaultSecrets = async () => { + let result: GetAzureKeyVaultSecret[] = []; + + let currentUrl = `${vaultBaseUrl}/secrets?api-version=7.3`; + + while (currentUrl) { + const res = await request.get<{ value: GetAzureKeyVaultSecret; nextLink: string }>(currentUrl, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + result = result.concat(res.data.value); + currentUrl = res.data.nextLink; + } + + return result; + }; + + const getAzureKeyVaultSecrets = await paginateAzureKeyVaultSecrets(); + + const enabledAzureKeyVaultSecrets = getAzureKeyVaultSecrets.filter((secret) => secret.attributes.enabled); + + // disabled keys to skip sending updates to + const disabledAzureKeyVaultSecretKeys = getAzureKeyVaultSecrets + .filter(({ attributes }) => !attributes.enabled) + .map((getAzureKeyVaultSecret) => { + return getAzureKeyVaultSecret.id.substring(getAzureKeyVaultSecret.id.lastIndexOf("/") + 1); + }); + + let lastSlashIndex: number; + const res = ( + await Promise.all( + enabledAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => { + if (!lastSlashIndex) { + lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf("/"); + } + + const azureKeyVaultSecret = await request.get( + `${getAzureKeyVaultSecret.id}?api-version=7.3`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + return { + ...azureKeyVaultSecret.data, + key: getAzureKeyVaultSecret.id.substring(lastSlashIndex + 1) + }; + }) + ) + ).reduce( + (obj, secret) => ({ + ...obj, + [secret.key]: secret + }), + {} as Record + ); + + return { + vaultSecrets: res, + disabledAzureKeyVaultSecretKeys + }; + }; + + const syncSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService); + + const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets( + accessToken, + secretSync.destinationConfig.vaultBaseUrl + ); + + const setSecrets: { + key: string; + value: string; + }[] = []; + + const deleteSecrets: string[] = []; + + Object.keys(secretMap).forEach((infisicalKey) => { + const hyphenatedKey = infisicalKey.replace(/_/g, "-"); + if (!(hyphenatedKey in vaultSecrets)) { + // case: secret has been created + setSecrets.push({ + key: hyphenatedKey, + value: secretMap[infisicalKey].value + }); + } else if (secretMap[infisicalKey].value !== vaultSecrets[hyphenatedKey].value) { + // case: secret has been updated + setSecrets.push({ + key: hyphenatedKey, + value: secretMap[infisicalKey].value + }); + } + }); + + Object.keys(vaultSecrets).forEach((key) => { + const underscoredKey = key.replace(/-/g, "_"); + if (!(underscoredKey in secretMap)) { + deleteSecrets.push(key); + } + }); + + const setSecretAzureKeyVault = async ({ key, value }: { key: string; value: string }) => { + let isSecretSet = false; + let maxTries = 6; + if (disabledAzureKeyVaultSecretKeys.includes(key)) return; + + while (!isSecretSet && maxTries > 0) { + // try to set secret + try { + await request.put( + `${secretSync.destinationConfig.vaultBaseUrl}/secrets/${key}?api-version=7.3`, + { + value + }, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + isSecretSet = true; + } catch (err) { + if (err instanceof AxiosError) { + // eslint-disable-next-line + if (err.response?.data?.error?.innererror?.code === "ObjectIsDeletedButRecoverable") { + await request.post( + `${secretSync.destinationConfig.vaultBaseUrl}/deletedsecrets/${key}/recover?api-version=7.3`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + await new Promise((resolve) => { + setTimeout(resolve, 10_000); + }); + } else { + await new Promise((resolve) => { + setTimeout(resolve, 10_000); + }); + maxTries -= 1; + } + } + } + } + }; + + for await (const setSecret of setSecrets) { + const { key, value } = setSecret; + await setSecretAzureKeyVault({ + key, + value + }); + } + + for await (const deleteSecretKey of deleteSecrets.filter( + (secret) => !setSecrets.find((setSecret) => setSecret.key === secret) + )) { + await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + } + }; + + const removeSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService); + + const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets( + accessToken, + secretSync.destinationConfig.vaultBaseUrl + ); + + for await (const [key] of Object.entries(vaultSecrets)) { + const underscoredKey = key.replace(/-/g, "_"); + + if (underscoredKey in secretMap) { + if (!disabledAzureKeyVaultSecretKeys.includes(underscoredKey)) { + await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${key}?api-version=7.3`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + } + } + } + }; + + const getSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials) => { + const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService); + + const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets( + accessToken, + secretSync.destinationConfig.vaultBaseUrl + ); + + const secretMap: TSecretMap = {}; + + Object.keys(vaultSecrets).forEach((key) => { + if (!disabledAzureKeyVaultSecretKeys.includes(key)) { + const underscoredKey = key.replace(/-/g, "_"); + secretMap[underscoredKey] = { + value: vaultSecrets[key].value + }; + } + }); + + return secretMap; + }; + + return { + syncSecrets, + removeSecrets, + getSecrets + }; +}; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts new file mode 100644 index 000000000..d991ca6a2 --- /dev/null +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const AzureKeyVaultSyncDestinationConfigSchema = z.object({ + vaultBaseUrl: z.string().min(1, "Vault base URL required") +}); + +const AzureKeyVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const AzureKeyVaultSyncSchema = BaseSecretSyncSchema( + SecretSync.AzureKeyVault, + AzureKeyVaultSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.AzureKeyVault), + destinationConfig: AzureKeyVaultSyncDestinationConfigSchema +}); + +export const CreateAzureKeyVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.AzureKeyVault, + AzureKeyVaultSyncOptionsConfig +).extend({ + destinationConfig: AzureKeyVaultSyncDestinationConfigSchema +}); + +export const UpdateAzureKeyVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.AzureKeyVault, + AzureKeyVaultSyncOptionsConfig +).extend({ + destinationConfig: AzureKeyVaultSyncDestinationConfigSchema.optional() +}); + +export const AzureKeyVaultSyncListItemSchema = z.object({ + name: z.literal("Azure Key Vault"), + connection: z.literal(AppConnection.Azure), + destination: z.literal(SecretSync.AzureKeyVault), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-types.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-types.ts new file mode 100644 index 000000000..930792aff --- /dev/null +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-types.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { TAzureConnection } from "@app/services/app-connection/azure"; + +import { + AzureKeyVaultSyncListItemSchema, + AzureKeyVaultSyncSchema, + CreateAzureKeyVaultSyncSchema +} from "./azure-key-vault-sync-schemas"; + +export type TAzureKeyVaultSync = z.infer; + +export type TAzureKeyVaultSyncInput = z.infer; + +export type TAzureKeyVaultSyncListItem = z.infer; + +export type TAzureKeyVaultSyncWithCredentials = TAzureKeyVaultSync & { + connection: TAzureConnection; +}; + +export interface GetAzureKeyVaultSecret { + id: string; // secret URI + value: string; + attributes: { + enabled: boolean; + created: number; + updated: number; + recoveryLevel: string; + recoverableDays: number; + }; +} + +export interface AzureKeyVaultSecret extends GetAzureKeyVaultSecret { + key: string; +} diff --git a/backend/src/services/secret-sync/azure-key-vault/index.ts b/backend/src/services/secret-sync/azure-key-vault/index.ts new file mode 100644 index 000000000..f2a7e036f --- /dev/null +++ b/backend/src/services/secret-sync/azure-key-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-key-vault-sync-constants"; +export * from "./azure-key-vault-sync-fns"; +export * from "./azure-key-vault-sync-schemas"; +export * from "./azure-key-vault-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 14d5f06c7..0bee11d95 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -2,7 +2,9 @@ export enum SecretSync { AWSParameterStore = "aws-parameter-store", AWSSecretsManager = "aws-secrets-manager", GitHub = "github", - GCPSecretManager = "gcp-secret-manager" + GCPSecretManager = "gcp-secret-manager", + AzureKeyVault = "azure-key-vault", + AzureAppConfiguration = "azure-app-configuration" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 184be9507..1d8e1595c 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -17,6 +17,13 @@ import { TSecretSyncWithCredentials } from "@app/services/secret-sync/secret-sync-types"; +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { + AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, + azureAppConfigurationSecretSyncFactory +} from "./azure-app-configuration"; +import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSecretSyncFactory } from "./azure-key-vault"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; @@ -24,13 +31,20 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, [SecretSync.AWSSecretsManager]: AWS_SECRETS_MANAGER_SYNC_LIST_OPTION, [SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION, - [SecretSync.GCPSecretManager]: GCP_SYNC_LIST_OPTION + [SecretSync.GCPSecretManager]: GCP_SYNC_LIST_OPTION, + [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, + [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); }; +type TSyncSecretDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + // const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => { // let secretMap = { ...unprocessedSecretMap }; // @@ -72,9 +86,23 @@ export const listSecretSyncOptions = () => { // }; export const SecretSyncFns = { - syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise => { + syncSecrets: ( + secretSync: TSecretSyncWithCredentials, + secretMap: TSecretMap, + { kmsService, appConnectionDAL }: TSyncSecretDeps + ): Promise => { // const affixedSecretMap = addAffixes(secretSync, secretMap); + const azureKeyVaultSecretSync = azureKeyVaultSecretSyncFactory({ + appConnectionDAL, + kmsService + }); + + const azureAppConfigurationSecretSync = azureAppConfigurationSecretSyncFactory({ + appConnectionDAL, + kmsService + }); + switch (secretSync.destination) { case SecretSync.AWSParameterStore: return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap); @@ -84,13 +112,25 @@ export const SecretSyncFns = { return GithubSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.GCPSecretManager: return GcpSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.AzureKeyVault: + return azureKeyVaultSecretSync.syncSecrets(secretSync, secretMap); + case SecretSync.AzureAppConfiguration: + return azureAppConfigurationSecretSync.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` ); } }, - getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise => { + getSecrets: async ( + secretSync: TSecretSyncWithCredentials, + { kmsService, appConnectionDAL }: TSyncSecretDeps + ): Promise => { + const azureKeyVaultSecretSync = azureKeyVaultSecretSyncFactory({ + appConnectionDAL, + kmsService + }); + let secretMap: TSecretMap; switch (secretSync.destination) { case SecretSync.AWSParameterStore: @@ -105,6 +145,10 @@ export const SecretSyncFns = { case SecretSync.GCPSecretManager: secretMap = await GcpSyncFns.getSecrets(secretSync); break; + case SecretSync.AzureKeyVault: + secretMap = await azureKeyVaultSecretSync.getSecrets(secretSync); + break; + default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -114,9 +158,18 @@ export const SecretSyncFns = { return secretMap; // return stripAffixes(secretSync, secretMap); }, - removeSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise => { + removeSecrets: ( + secretSync: TSecretSyncWithCredentials, + secretMap: TSecretMap, + { kmsService, appConnectionDAL }: TSyncSecretDeps + ): Promise => { // const affixedSecretMap = addAffixes(secretSync, secretMap); + const azureKeyVaultSecretSync = azureKeyVaultSecretSyncFactory({ + appConnectionDAL, + kmsService + }); + switch (secretSync.destination) { case SecretSync.AWSParameterStore: return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap); @@ -126,6 +179,8 @@ export const SecretSyncFns = { return GithubSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.GCPSecretManager: return GcpSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.AzureKeyVault: + return azureKeyVaultSecretSync.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 8ed4cdb1b..507f11baa 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -5,12 +5,16 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", [SecretSync.AWSSecretsManager]: "AWS Secrets Manager", [SecretSync.GitHub]: "GitHub", - [SecretSync.GCPSecretManager]: "GCP Secret Manager" + [SecretSync.GCPSecretManager]: "GCP Secret Manager", + [SecretSync.AzureKeyVault]: "Azure Key Vault", + [SecretSync.AzureAppConfiguration]: "Azure App Configuration" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AWSParameterStore]: AppConnection.AWS, [SecretSync.AWSSecretsManager]: AppConnection.AWS, [SecretSync.GitHub]: AppConnection.GitHub, - [SecretSync.GCPSecretManager]: AppConnection.GCP + [SecretSync.GCPSecretManager]: AppConnection.GCP, + [SecretSync.AzureKeyVault]: AppConnection.Azure, + [SecretSync.AzureAppConfiguration]: AppConnection.Azure }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index d2bcdb590..e822a20b7 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -57,11 +57,14 @@ import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secre import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; + export type TSecretSyncQueueFactory = ReturnType; type TSecretSyncQueueFactoryDep = { queueService: Pick; kmsService: Pick; + appConnectionDAL: Pick; keyStore: Pick; folderDAL: TSecretFolderDALFactory; secretV2BridgeDAL: Pick< @@ -111,6 +114,7 @@ const getRequeueDelay = (failureCount?: number) => { export const secretSyncQueueFactory = ({ queueService, kmsService, + appConnectionDAL, keyStore, folderDAL, secretV2BridgeDAL, @@ -322,7 +326,10 @@ export const secretSyncQueueFactory = ({ "Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path." ); - const importedSecrets = await SecretSyncFns.getSecrets(secretSync); + const importedSecrets = await SecretSyncFns.getSecrets(secretSync, { + appConnectionDAL, + kmsService + }); if (!Object.keys(importedSecrets).length) return {}; @@ -434,7 +441,10 @@ export const secretSyncQueueFactory = ({ }); } - await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap); + await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap, { + appConnectionDAL, + kmsService + }); isSynced = true; } catch (err) { @@ -672,7 +682,11 @@ export const secretSyncQueueFactory = ({ credentials } } as TSecretSyncWithCredentials, - secretMap + secretMap, + { + appConnectionDAL, + kmsService + } ); isSuccess = true; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 9ce331e8e..bf43ae927 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -23,27 +23,51 @@ import { TAwsParameterStoreSyncListItem, TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store"; +import { + TAzureAppConfigurationSync, + TAzureAppConfigurationSyncInput, + TAzureAppConfigurationSyncListItem, + TAzureAppConfigurationSyncWithCredentials +} from "./azure-app-configuration"; +import { + TAzureKeyVaultSync, + TAzureKeyVaultSyncInput, + TAzureKeyVaultSyncListItem, + TAzureKeyVaultSyncWithCredentials +} from "./azure-key-vault"; import { TGcpSync, TGcpSyncInput, TGcpSyncListItem, TGcpSyncWithCredentials } from "./gcp"; -export type TSecretSync = TAwsParameterStoreSync | TAwsSecretsManagerSync | TGitHubSync | TGcpSync; +export type TSecretSync = + | TAwsParameterStoreSync + | TAwsSecretsManagerSync + | TGitHubSync + | TGcpSync + | TAzureKeyVaultSync + | TAzureAppConfigurationSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials | TAwsSecretsManagerSyncWithCredentials | TGitHubSyncWithCredentials - | TGcpSyncWithCredentials; + | TGcpSyncWithCredentials + | TAzureKeyVaultSyncWithCredentials + | TAzureAppConfigurationSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput | TAwsSecretsManagerSyncInput | TGitHubSyncInput - | TGcpSyncInput; + | TGcpSyncInput + | TAzureKeyVaultSyncInput + | TAzureAppConfigurationSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem | TAwsSecretsManagerSyncListItem | TGitHubSyncListItem - | TGcpSyncListItem; + | TGcpSyncListItem + | TAzureKeyVaultSyncListItem + | TAzureAppConfigurationSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx index 408e4af67..6301fd21a 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -8,22 +9,28 @@ import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SECRET_SYNC_CONNECTION_MAP } from "@app/helpers/secretSyncs"; -import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { + TAvailableAppConnection, + useListAvailableAppConnections +} from "@app/hooks/api/appConnections"; import { TSecretSyncForm } from "./schemas"; type Props = { onChange?: VoidFunction; + filterConnections?: ( + connections?: TAvailableAppConnection[] + ) => TAvailableAppConnection[] | undefined; }; -export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { +export const SecretSyncConnectionField = ({ onChange: callback, filterConnections }: Props) => { const { permission } = useOrgPermission(); const { control, watch } = useFormContext(); const destination = watch("destination"); const app = SECRET_SYNC_CONNECTION_MAP[destination]; - const { data: options, isLoading } = useListAvailableAppConnections(app); + const { data: allConnections, isLoading } = useListAvailableAppConnections(app); const connectionName = APP_CONNECTION_MAP[app].name; @@ -32,6 +39,10 @@ export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { OrgPermissionSubjects.AppConnections ); + const availableConnections = useMemo(() => { + return filterConnections ? filterConnections(allConnections) : allConnections; + }, [allConnections]); + const appName = APP_CONNECTION_MAP[SECRET_SYNC_CONNECTION_MAP[destination]].name; return ( @@ -55,7 +66,7 @@ export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { if (callback) callback(); }} isLoading={isLoading} - options={options} + options={availableConnections} placeholder="Select connection..." getOptionLabel={(option) => option.name} getOptionValue={(option) => option.id} @@ -65,7 +76,7 @@ export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { control={control} name="connection" /> - {options?.length === 0 && ( + {availableConnections?.length === 0 && (

{canCreateConnection ? ( diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureAppConfigurationSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureAppConfigurationSyncFields.tsx new file mode 100644 index 000000000..739009182 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureAppConfigurationSyncFields.tsx @@ -0,0 +1,64 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FormControl, Input } from "@app/components/v2"; +import { AzureResources } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const AzureAppConfigurationSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AzureAppConfiguration } + >(); + + return ( + <> + { + setValue("destinationConfig.configurationUrl", ""); + }} + filterConnections={(connections) => { + if (!connections) return connections; + + return connections.filter( + (connection) => + connection.app === AppConnection.Azure && + connection.azureResource === AzureResources.AppConfiguration + ); + }} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureKeyVaultSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureKeyVaultSyncFields.tsx new file mode 100644 index 000000000..4993f3af2 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureKeyVaultSyncFields.tsx @@ -0,0 +1,48 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FormControl, Input } from "@app/components/v2"; +import { AzureResources } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const AzureKeyVaultSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AzureKeyVault } + >(); + + return ( + <> + { + setValue("destinationConfig.vaultBaseUrl", ""); + }} + filterConnections={(connections) => { + if (!connections) return connections; + + return connections.filter( + (connection) => + connection.app === AppConnection.Azure && + connection.azureResource === AzureResources.KeyVault + ); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 262677a6f..23724f7b5 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -5,6 +5,8 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; +import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; +import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; @@ -22,6 +24,10 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.GCPSecretManager: return ; + case SecretSync.AzureKeyVault: + return ; + case SecretSync.AzureAppConfiguration: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx new file mode 100644 index 000000000..e318397ec --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureAppConfigurationSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureAppConfigurationSyncReviewFields = () => { + const { watch } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AzureAppConfiguration } + >(); + const vaultBaseUrl = watch("destinationConfig.configurationUrl"); + const label = watch("destinationConfig.label"); + + return ( + <> + {vaultBaseUrl} + {label} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx new file mode 100644 index 000000000..94a0b985e --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureKeyVaultSyncReviewFields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureKeyVaultSyncReviewFields = () => { + const { watch } = useFormContext(); + const vaultBaseUrl = watch("destinationConfig.vaultBaseUrl"); + + return {vaultBaseUrl}; +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index c343532cb..a772b603b 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -9,6 +9,8 @@ import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/hel import { SecretSync } from "@app/hooks/api/secretSyncs"; import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields"; +import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields"; +import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; @@ -46,6 +48,12 @@ export const SecretSyncReviewFields = () => { case SecretSync.GCPSecretManager: DestinationFieldsComponent = ; break; + case SecretSync.AzureKeyVault: + DestinationFieldsComponent = ; + break; + case SecretSync.AzureAppConfiguration: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts new file mode 100644 index 000000000..d472ddb04 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureAppConfigurationSyncDestinationSchema = z.object({ + destination: z.literal(SecretSync.AzureAppConfiguration), + destinationConfig: z.object({ + configurationUrl: z + .string() + .trim() + .min(1, { message: "Azure App Configuration URL is required" }) + .url() + .refine( + (val) => val.endsWith(".azconfig.io"), + "URL should have the following format: https://resource-name-here.azconfig.io" + ), + label: z.string().optional() + }) +}); diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts new file mode 100644 index 000000000..49e3643da --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureKeyVaultSyncDestinationSchema = z.object({ + destination: z.literal(SecretSync.AzureKeyVault), + destinationConfig: z.object({ + vaultBaseUrl: z.string().min(1, "Vault Base URL required") + }) +}); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 817beafde..28fe232d3 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -6,6 +6,8 @@ import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; import { slugSchema } from "@app/lib/schemas"; import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; +import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; +import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; const BaseSecretSyncSchema = z.object({ @@ -35,7 +37,9 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, AwsSecretsManagerSyncDestinationSchema, GitHubSyncDestinationSchema, - GcpSyncDestinationSchema + GcpSyncDestinationSchema, + AzureKeyVaultSyncDestinationSchema, + AzureAppConfigurationSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema); diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 42823af4d..6a41e42b8 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -46,9 +46,9 @@ export const ROUTE_PATHS = Object.freeze({ "/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId" ), AppConnections: { - GithubOauthCallbackPage: setRoute( - "/organization/app-connections/github/oauth/callback", - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback" + OauthCallbackPage: setRoute( + "/organization/app-connections/$appConnection/oauth/callback", + "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" ) } }, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 1349395b8..bea536a41 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -15,7 +15,8 @@ export const APP_CONNECTION_MAP: Record { diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index a8f2bcf5c..c064b091d 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -9,14 +9,21 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.AWSParameterStore]: AppConnection.AWS, [SecretSync.AWSSecretsManager]: AppConnection.AWS, [SecretSync.GitHub]: AppConnection.GitHub, - [SecretSync.GCPSecretManager]: AppConnection.GCP + [SecretSync.GCPSecretManager]: AppConnection.GCP, + [SecretSync.AzureKeyVault]: AppConnection.Azure, + [SecretSync.AzureAppConfiguration]: AppConnection.Azure }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index ba29a3781..3f941fc94 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -1,5 +1,6 @@ export enum AppConnection { AWS = "aws", GitHub = "github", - GCP = "gcp" + GCP = "gcp", + Azure = "azure" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 91fc25cc2..a7a17639c 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -20,10 +20,16 @@ export type TGcpConnectionOption = TAppConnectionOptionBase & { app: AppConnection.GCP; }; +export type TAzureConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Azure; + oauthClientId?: string; +}; + export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; [AppConnection.GitHub]: TGitHubConnectionOption; [AppConnection.GCP]: TGcpConnectionOption; + [AppConnection.Azure]: TAzureConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-connection.ts new file mode 100644 index 000000000..d8d194b6a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-connection.ts @@ -0,0 +1,26 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AzureConnectionMethod { + OAuth = "oauth" +} + +export enum AzureResources { + KeyVault = "key-vault", + AppConfiguration = "app-configuration" +} + +export const azureResourcesMap: Record = { + [AzureResources.AppConfiguration]: "App Configuration", + [AzureResources.KeyVault]: "Key Vault" +}; + +export type TAzureConnection = TRootAppConnection & { app: AppConnection.Azure } & { + method: AzureConnectionMethod.OAuth; + resource: AzureResources; + credentials: { + code: string; + tenantId?: string; + resource: AzureResources; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 283a6d1a0..68afe72b4 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,15 +3,22 @@ import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-op import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; +import { AzureResources, TAzureConnection } from "./azure-connection"; import { TGcpConnection } from "./gcp-connection"; export * from "./aws-connection"; +export * from "./azure-connection"; export * from "./gcp-connection"; export * from "./github-connection"; -export type TAppConnection = TAwsConnection | TGitHubConnection | TGcpConnection; +export type TAppConnection = TAwsConnection | TGitHubConnection | TGcpConnection | TAzureConnection; -export type TAvailableAppConnection = Pick; +export type TAvailableAppConnection = + | (Pick & { app: Exclude }) + | (Pick & { + app: AppConnection.Azure; + azureResource?: AzureResources; + }); export type TListAppConnections = { appConnections: T[] }; export type TGetAppConnection = { appConnection: T }; @@ -40,4 +47,5 @@ export type TAppConnectionMap = { [AppConnection.AWS]: TAwsConnection; [AppConnection.GitHub]: TGitHubConnection; [AppConnection.GCP]: TGcpConnection; + [AppConnection.Azure]: TAzureConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 0f9c820b5..e141693b5 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -2,7 +2,9 @@ export enum SecretSync { AWSParameterStore = "aws-parameter-store", AWSSecretsManager = "aws-secrets-manager", GitHub = "github", - GCPSecretManager = "gcp-secret-manager" + GCPSecretManager = "gcp-secret-manager", + AzureKeyVault = "azure-key-vault", + AzureAppConfiguration = "azure-app-configuration" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/azure-app-configuration-sync.ts b/frontend/src/hooks/api/secretSyncs/types/azure-app-configuration-sync.ts new file mode 100644 index 000000000..2e894ba25 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/azure-app-configuration-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TAzureAppConfigurationSync = TRootSecretSync & { + destination: SecretSync.AzureAppConfiguration; + destinationConfig: { + configurationUrl: string; + label?: string; + }; + connection: { + app: AppConnection.Azure; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/azure-key-vault-sync.ts b/frontend/src/hooks/api/secretSyncs/types/azure-key-vault-sync.ts new file mode 100644 index 000000000..4cd907a03 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/azure-key-vault-sync.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TAzureKeyVaultSync = TRootSecretSync & { + destination: SecretSync.AzureKeyVault; + destinationConfig: { + vaultBaseUrl: string; + }; + connection: { + app: AppConnection.Azure; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f7e718a0b..51f60810c 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -4,6 +4,8 @@ import { TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; import { DiscriminativePick } from "@app/types"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; +import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; +import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TGcpSync } from "./gcp-sync"; export type TSecretSyncOption = { @@ -12,7 +14,13 @@ export type TSecretSyncOption = { canImportSecrets: boolean; }; -export type TSecretSync = TAwsParameterStoreSync | TAwsSecretsManagerSync | TGitHubSync | TGcpSync; +export type TSecretSync = + | TAwsParameterStoreSync + | TAwsSecretsManagerSync + | TGitHubSync + | TGcpSync + | TAzureKeyVaultSync + | TAzureAppConfigurationSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx deleted file mode 100644 index a71bb1744..000000000 --- a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { useEffect } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; - -import { createNotification } from "@app/components/notifications"; -import { ContentLoader } from "@app/components/v2"; -import { ROUTE_PATHS } from "@app/const/routes"; -import { - GitHubConnectionMethod, - TGitHubConnection, - useCreateAppConnection, - useUpdateAppConnection -} from "@app/hooks/api/appConnections"; -import { AppConnection } from "@app/hooks/api/appConnections/enums"; - -type FormData = Pick & { - returnUrl?: string; - connectionId?: string; -}; - -export const GitHubOAuthCallbackPage = () => { - const navigate = useNavigate(); - const search = useSearch({ - from: ROUTE_PATHS.Organization.AppConnections.GithubOauthCallbackPage.id - }); - const updateAppConnection = useUpdateAppConnection(); - const createAppConnection = useCreateAppConnection(); - - const { code, state, installation_id: installationId } = search; - - useEffect(() => { - (async () => { - let formData: FormData; - - try { - formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData; - } catch { - createNotification({ - type: "error", - text: "Invalid form state, redirecting..." - }); - navigate({ to: "/" }); - return; - } - - // validate state - if (state !== localStorage.getItem("latestCSRFToken")) { - return; - } - - localStorage.removeItem("githubConnectionFormData"); - localStorage.removeItem("latestCSRFToken"); - - const { connectionId, name, description, returnUrl } = formData; - - try { - if (connectionId) { - await updateAppConnection.mutateAsync({ - app: AppConnection.GitHub, - ...(installationId - ? { - connectionId, - credentials: { - code: code as string, - installationId: installationId as string - } - } - : { - connectionId, - credentials: { - code: code as string - } - }) - }); - } else { - await createAppConnection.mutateAsync({ - app: AppConnection.GitHub, - name, - description, - ...(installationId - ? { - method: GitHubConnectionMethod.App, - credentials: { - code: code as string, - installationId: installationId as string - } - } - : { - method: GitHubConnectionMethod.OAuth, - credentials: { - code: code as string - } - }) - }); - } - } catch (e: any) { - createNotification({ - title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`, - text: e.message, - type: "error" - }); - navigate({ - to: returnUrl ?? "/organization/settings?selectedTab=app-connections" - }); - return; - } - - createNotification({ - text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`, - type: "success" - }); - - navigate({ - to: returnUrl ?? "/organization/settings?selectedTab=app-connections" - }); - })(); - }, []); - - return ( -

- -
- ); -}; diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx new file mode 100644 index 000000000..8b861d192 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + AzureConnectionMethod, + AzureResources, + GitHubConnectionMethod, + TGitHubConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type GithubFormData = Pick & { + returnUrl?: string; + connectionId?: string; +}; + +type AzureFormData = Pick & { + returnUrl?: string; + connectionId?: string; + tenantId?: string; + resource: AzureResources; +}; + +type FormDataMap = { + [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; + [AppConnection.Azure]: AzureFormData & { app: AppConnection.Azure }; +}; + +const formDataStorageFieldMap: Partial> = { + [AppConnection.GitHub]: "githubConnectionFormData", + [AppConnection.Azure]: "azureConnectionFormData" +}; + +export const OAuthCallbackPage = () => { + const navigate = useNavigate(); + const [isReady, setIsReady] = useState(false); + + const search = useSearch({ + from: ROUTE_PATHS.Organization.AppConnections.OauthCallbackPage.id + }); + + const appConnection = useParams({ + strict: false, + select: (el) => el?.appConnection as AppConnection + }); + + const updateAppConnection = useUpdateAppConnection(); + const createAppConnection = useCreateAppConnection(); + + const { code, state, installation_id: installationId } = search; + + const clearState = (app: AppConnection) => { + if (state !== localStorage.getItem("latestCSRFToken")) { + throw new Error("Invalid CSRF token"); + } + + const dataFieldName = formDataStorageFieldMap[app]; + + localStorage.removeItem(dataFieldName!); + localStorage.removeItem("latestCSRFToken"); + }; + + const getFormData = (app: T): FormDataMap[T] | null => { + const dataFieldName = formDataStorageFieldMap[app]; + + try { + const rawData = JSON.parse(localStorage.getItem(dataFieldName!) ?? "{}"); + + return { + ...rawData, + app + } as FormDataMap[T]; + } catch { + createNotification({ + type: "error", + text: `Invalid ${app || ""} form state, redirecting...` + }); + navigate({ to: "/" }); + return null; + } + }; + + const handleAzure = useCallback(async () => { + const formData = getFormData(AppConnection.Azure); + if (formData === null) return null; + + clearState(AppConnection.Azure); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.Azure, + connectionId, + credentials: { + code: code as string + } + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.Azure, + name, + description, + method: AzureConnectionMethod.OAuth, + credentials: { + resource: formData.resource, + tenantId: formData.tenantId, + code: code as string + } + }); + } + } catch (err: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} Azure Connection`, + text: err?.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/settings?selectedTab=app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + + const handleGithub = useCallback(async () => { + const formData = getFormData(AppConnection.GitHub); + if (formData === null) return null; + + clearState(AppConnection.GitHub); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.GitHub, + ...(installationId + ? { + connectionId, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + connectionId, + credentials: { + code: code as string + } + }) + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.GitHub, + name, + description, + ...(installationId + ? { + method: GitHubConnectionMethod.App, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + method: GitHubConnectionMethod.OAuth, + credentials: { + code: code as string + } + }) + }); + } + } catch (e: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`, + text: e.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/settings?selectedTab=app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + + // Ensure that the localstorage is ready for use, to avoid the form data being malformed + useEffect(() => { + if (!isReady) { + setIsReady(!!localStorage.length); + } + }, [localStorage.length]); + + useEffect(() => { + if (!isReady) return; + + (async () => { + let data: { connectionId?: string; returnUrl?: string; appConnectionName?: string } | null = + null; + + if (appConnection === AppConnection.GitHub) { + data = await handleGithub(); + } else if (appConnection === AppConnection.Azure) { + data = await handleAzure(); + } + + if (data) { + createNotification({ + text: `Successfully ${data.connectionId ? "updated" : "added"} ${data.appConnectionName || ""} Connection`, + type: "success" + }); + } + + await navigate({ + to: data?.returnUrl ?? "/organization/settings?selectedTab=app-connections" + }); + })(); + }, [isReady]); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx similarity index 81% rename from frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx rename to frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx index a69c27aee..4a4dfa848 100644 --- a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx @@ -2,7 +2,7 @@ import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; -import { GitHubOAuthCallbackPage } from "./GithubOauthCallbackPage"; +import { OAuthCallbackPage } from "./OauthCallbackPage"; const GitHubOAuthCallbackPageQueryParamsSchema = z.object({ code: z.coerce.string().catch(""), @@ -11,9 +11,9 @@ const GitHubOAuthCallbackPageQueryParamsSchema = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback" + "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" )({ - component: GitHubOAuthCallbackPage, + component: OAuthCallbackPage, validateSearch: zodValidator(GitHubOAuthCallbackPageQueryParamsSchema), search: { middlewares: [stripSearchParams({ state: "", installation_id: "" })] diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx index 99a18a6fc..b5b91a8e8 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx @@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types"; import { AppConnectionHeader } from "../AppConnectionHeader"; import { AwsConnectionForm } from "./AwsConnectionForm"; +import { AzureConnectionForm } from "./AzureConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; @@ -53,6 +54,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.GCP: return ; + case AppConnection.Azure: + return ; default: throw new Error(`Unhandled App ${app}`); } diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AzureConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AzureConnectionForm.tsx new file mode 100644 index 000000000..23d05a09e --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AzureConnectionForm.tsx @@ -0,0 +1,208 @@ +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 { useGetAppConnectionOption } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + AzureConnectionMethod, + AzureResources, + TAzureConnection +} from "@app/hooks/api/appConnections/types/azure-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAzureConnection; +}; + +const resourceScopes: Record = { + [AzureResources.AppConfiguration]: "https://azconfig.io/.default", + [AzureResources.KeyVault]: "https://vault.azure.net/.default" +}; + +const formSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Azure), + method: z.nativeEnum(AzureConnectionMethod), + tenantId: z.string().trim().optional(), + resource: z.nativeEnum(AzureResources) +}); + +type FormData = z.infer; + +export const AzureConnectionForm = ({ appConnection }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.Azure); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Azure, + method: AzureConnectionMethod.OAuth, + resource: AzureResources.KeyVault + } + }); + + 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( + "azureConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); + + switch (formData.method) { + case AzureConnectionMethod.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=${resourceScopes[formData.resource]}%20openid%20offline_access&state=${state}` + ); + break; + default: + throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); + } + }; + + let isMissingConfig: boolean; + + switch (selectedMethod) { + case AzureConnectionMethod.OAuth: + isMissingConfig = !oauthClientId; + break; + default: + throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`); + } + + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx index a733bae8e..a63f1b59d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx @@ -27,7 +27,8 @@ import { OrgPermissionSubjects } from "@app/context"; import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { useToggle } from "@app/hooks"; -import { TAppConnection } from "@app/hooks/api/appConnections"; +import { azureResourcesMap, TAppConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; type Props = { appConnection: TAppConnection; @@ -42,7 +43,7 @@ export const AppConnectionRow = ({ onEditCredentials, onEditDetails }: Props) => { - const { id, name, method, app, description } = appConnection; + const { id, name, method, app, description, credentials } = appConnection; const [isIdCopied, setIsIdCopied] = useToggle(false); @@ -75,7 +76,10 @@ export const AppConnectionRow = ({ src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`} className="mr-0.5 h-5 w-5" /> - {APP_CONNECTION_MAP[app].name} + + {APP_CONNECTION_MAP[app].name} + {app === AppConnection.Azure && ` ${azureResourcesMap[credentials.resource]}`} + diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureAppConfigurationDestinationSyncCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureAppConfigurationDestinationSyncCol.tsx new file mode 100644 index 000000000..5a80a5b35 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureAppConfigurationDestinationSyncCol.tsx @@ -0,0 +1,14 @@ +import { TAzureAppConfigurationSync } from "@app/hooks/api/secretSyncs/types/azure-app-configuration-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TAzureAppConfigurationSync; +}; + +export const AzureAppConfigurationDestinationSyncCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureKeyVaultDestinationSyncCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureKeyVaultDestinationSyncCol.tsx new file mode 100644 index 000000000..149861d33 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AzureKeyVaultDestinationSyncCol.tsx @@ -0,0 +1,14 @@ +import { TAzureKeyVaultSync } from "@app/hooks/api/secretSyncs/types/azure-key-vault-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TAzureKeyVaultSync; +}; + +export const AzureKeyVaultDestinationSyncCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 376258bfb..70e8acf4b 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -2,6 +2,8 @@ import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol"; import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDestinationCol"; +import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol"; +import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; @@ -19,6 +21,11 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.GCPSecretManager: return ; + case SecretSync.AzureKeyVault: + return ; + case SecretSync.AzureAppConfiguration: + return ; + default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 647770ee8..7b98c183b 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -47,6 +47,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.projectId; secondaryText = "Global"; break; + case SecretSync.AzureKeyVault: + primaryText = destinationConfig.vaultBaseUrl; + break; + case SecretSync.AzureAppConfiguration: + primaryText = destinationConfig.configurationUrl; + if (destinationConfig.label) { + secondaryText = `Label - ${destinationConfig.label}`; + } + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx new file mode 100644 index 000000000..c2535e114 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureAppConfigurationSyncDestinationSection.tsx @@ -0,0 +1,21 @@ +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TAzureAppConfigurationSync } from "@app/hooks/api/secretSyncs/types/azure-app-configuration-sync"; + +type Props = { + secretSync: TAzureAppConfigurationSync; +}; + +export const AzureAppConfigurationSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { configurationUrl, label } + } = secretSync; + + return ( + <> + {configurationUrl} + + {label && label.length > 0 ? label : Not set} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx new file mode 100644 index 000000000..4a30e6e08 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AzureKeyVaultSyncDestinationSection.tsx @@ -0,0 +1,14 @@ +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TAzureKeyVaultSync } from "@app/hooks/api/secretSyncs/types/azure-key-vault-sync"; + +type Props = { + secretSync: TAzureKeyVaultSync; +}; + +export const AzureKeyVaultSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { vaultBaseUrl } + } = secretSync; + + return {vaultBaseUrl}; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 686f3498c..473ca8ecf 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -13,6 +13,8 @@ import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manag import { AwsSecretsManagerSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection"; import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection"; +import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; +import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; type Props = { @@ -39,6 +41,15 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.GCPSecretManager: DestinationComponents = ; break; + case SecretSync.AzureKeyVault: + DestinationComponents = ; + break; + case SecretSync.AzureAppConfiguration: + DestinationComponents = ( + + ); + break; + default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cc89058c3..ca3a45155 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -101,7 +101,7 @@ import { Route as sshSshCaByIDPageRouteImport } from './pages/ssh/SshCaByIDPage/ import { Route as secretManagerSecretDashboardPageRouteImport } from './pages/secret-manager/SecretDashboardPage/route' import { Route as secretManagerIntegrationsSelectIntegrationAuthPageRouteImport } from './pages/secret-manager/integrations/SelectIntegrationAuthPage/route' import { Route as secretManagerIntegrationsDetailsByIDPageRouteImport } from './pages/secret-manager/IntegrationsDetailsByIDPage/route' -import { Route as organizationAppConnectionsGithubOauthCallbackPageRouteImport } from './pages/organization/AppConnections/GithubOauthCallbackPage/route' +import { Route as organizationAppConnectionsOauthCallbackPageRouteImport } from './pages/organization/AppConnections/OauthCallbackPage/route' import { Route as certManagerCertAuthDetailsByIDPageRouteImport } from './pages/cert-manager/CertAuthDetailsByIDPage/route' import { Route as secretManagerIntegrationsListPageRouteImport } from './pages/secret-manager/IntegrationsListPage/route' import { Route as secretManagerIntegrationsWindmillConfigurePageRouteImport } from './pages/secret-manager/integrations/WindmillConfigurePage/route' @@ -916,10 +916,10 @@ const secretManagerIntegrationsDetailsByIDPageRouteRoute = AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsRoute, } as any) -const organizationAppConnectionsGithubOauthCallbackPageRouteRoute = - organizationAppConnectionsGithubOauthCallbackPageRouteImport.update({ - id: '/app-connections/github/oauth/callback', - path: '/app-connections/github/oauth/callback', +const organizationAppConnectionsOauthCallbackPageRouteRoute = + organizationAppConnectionsOauthCallbackPageRouteImport.update({ + id: '/app-connections/$appConnection/oauth/callback', + path: '/app-connections/$appConnection/oauth/callback', getParentRoute: () => AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute, } as any) @@ -2139,11 +2139,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof certManagerCertAuthDetailsByIDPageRouteImport parentRoute: typeof certManagerLayoutImport } - '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback': { - id: '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback' - path: '/app-connections/github/oauth/callback' - fullPath: '/organization/app-connections/github/oauth/callback' - preLoaderRoute: typeof organizationAppConnectionsGithubOauthCallbackPageRouteImport + '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': { + id: '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback' + path: '/app-connections/$appConnection/oauth/callback' + fullPath: '/organization/app-connections/$appConnection/oauth/callback' + preLoaderRoute: typeof organizationAppConnectionsOauthCallbackPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport } '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId': { @@ -2851,7 +2851,7 @@ interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren { organizationRoleByIDPageRouteRoute: typeof organizationRoleByIDPageRouteRoute organizationSecretManagerOverviewPageRouteRoute: typeof organizationSecretManagerOverviewPageRouteRoute organizationSshOverviewPageRouteRoute: typeof organizationSshOverviewPageRouteRoute - organizationAppConnectionsGithubOauthCallbackPageRouteRoute: typeof organizationAppConnectionsGithubOauthCallbackPageRouteRoute + organizationAppConnectionsOauthCallbackPageRouteRoute: typeof organizationAppConnectionsOauthCallbackPageRouteRoute } const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren: AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren = @@ -2882,8 +2882,8 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren: Authentica organizationSecretManagerOverviewPageRouteRoute, organizationSshOverviewPageRouteRoute: organizationSshOverviewPageRouteRoute, - organizationAppConnectionsGithubOauthCallbackPageRouteRoute: - organizationAppConnectionsGithubOauthCallbackPageRouteRoute, + organizationAppConnectionsOauthCallbackPageRouteRoute: + organizationAppConnectionsOauthCallbackPageRouteRoute, } const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteWithChildren = @@ -3576,7 +3576,7 @@ export interface FileRoutesByFullPath { '/ssh/$projectId/access-management': typeof projectAccessControlPageRouteSshRoute '/secret-manager/$projectId/integrations/': typeof secretManagerIntegrationsListPageRouteRoute '/cert-manager/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute - '/organization/app-connections/github/oauth/callback': typeof organizationAppConnectionsGithubOauthCallbackPageRouteRoute + '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute '/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute @@ -3742,7 +3742,7 @@ export interface FileRoutesByTo { '/ssh/$projectId/access-management': typeof projectAccessControlPageRouteSshRoute '/secret-manager/$projectId/integrations': typeof secretManagerIntegrationsListPageRouteRoute '/cert-manager/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute - '/organization/app-connections/github/oauth/callback': typeof organizationAppConnectionsGithubOauthCallbackPageRouteRoute + '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute '/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute @@ -3923,7 +3923,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management': typeof projectAccessControlPageRouteSshRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/': typeof secretManagerIntegrationsListPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute - '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback': typeof organizationAppConnectionsGithubOauthCallbackPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId': typeof secretManagerIntegrationsDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute @@ -4096,7 +4096,7 @@ export interface FileRouteTypes { | '/ssh/$projectId/access-management' | '/secret-manager/$projectId/integrations/' | '/cert-manager/$projectId/ca/$caId' - | '/organization/app-connections/github/oauth/callback' + | '/organization/app-connections/$appConnection/oauth/callback' | '/secret-manager/$projectId/integrations/$integrationId' | '/secret-manager/$projectId/integrations/select-integration-auth' | '/secret-manager/$projectId/secrets/$envSlug' @@ -4261,7 +4261,7 @@ export interface FileRouteTypes { | '/ssh/$projectId/access-management' | '/secret-manager/$projectId/integrations' | '/cert-manager/$projectId/ca/$caId' - | '/organization/app-connections/github/oauth/callback' + | '/organization/app-connections/$appConnection/oauth/callback' | '/secret-manager/$projectId/integrations/$integrationId' | '/secret-manager/$projectId/integrations/select-integration-auth' | '/secret-manager/$projectId/secrets/$envSlug' @@ -4440,7 +4440,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId' - | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback' + | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug' @@ -4767,7 +4767,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId", "/_authenticate/_inject-org-details/_org-layout/organization/secret-manager/overview", "/_authenticate/_inject-org-details/_org-layout/organization/ssh/overview", - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback" + "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" ] }, "/_authenticate/_inject-org-details/admin/_admin-layout": { @@ -5117,8 +5117,8 @@ export const routeTree = rootRoute "filePath": "cert-manager/CertAuthDetailsByIDPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" }, - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback": { - "filePath": "organization/AppConnections/GithubOauthCallbackPage/route.tsx", + "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback": { + "filePath": "organization/AppConnections/OauthCallbackPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organization" }, "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId": { @@ -5491,4 +5491,4 @@ export const routeTree = rootRoute } } } -ROUTE_MANIFEST_END */ \ No newline at end of file +ROUTE_MANIFEST_END */ diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 5a7f3645b..8cdd4087c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -24,9 +24,10 @@ const organizationRoutes = route("/organization", [ route("/members/$membershipId", "organization/UserDetailsByIDPage/route.tsx"), route("/roles/$roleId", "organization/RoleByIDPage/route.tsx"), route("/identities/$identityId", "organization/IdentityDetailsByIDPage/route.tsx"), + route( - "/app-connections/github/oauth/callback", - "organization/AppConnections/GithubOauthCallbackPage/route.tsx" + "/app-connections/$appConnection/oauth/callback", + "organization/AppConnections/OauthCallbackPage/route.tsx" ) ]);