diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fabcae408..5326a977c 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1126,6 +1126,7 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", + azureLabel: "Define which label to assign to secrets created in Azure App Configuration.", githubVisibility: "Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.", githubVisibilityRepoIds: diff --git a/backend/src/services/integration-auth/integration-sync-secret-fns.ts b/backend/src/services/integration-auth/integration-sync-secret-fns.ts new file mode 100644 index 000000000..df8b990af --- /dev/null +++ b/backend/src/services/integration-auth/integration-sync-secret-fns.ts @@ -0,0 +1,35 @@ +export const isAzureKeyVaultReference = (uri: string) => { + const tryJsonDecode = () => { + try { + return (JSON.parse(uri) as { uri: string }).uri || uri; + } catch { + return uri; + } + }; + + const cleanUri = tryJsonDecode(); + + if (!cleanUri.startsWith("https://")) { + return false; + } + + if (!cleanUri.includes(".vault.azure.net/secrets/")) { + return false; + } + + // 3. Check for non-empty string between https:// and .vault.azure.net/secrets/ + const parts = cleanUri.split(".vault.azure.net/secrets/"); + const vaultName = parts[0].replace("https://", ""); + if (!vaultName) { + return false; + } + + // 4. Check for non-empty secret name + const secretParts = parts[1].split("/"); + const secretName = secretParts[0]; + if (!secretName) { + return false; + } + + return true; +}; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index cd8b8baea..4fd139608 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -51,6 +51,7 @@ import { Integrations, IntegrationUrls } from "./integration-list"; +import { isAzureKeyVaultReference } from "./integration-sync-secret-fns"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -325,11 +326,12 @@ const syncSecretsAzureAppConfig = async ({ }; const metadata = IntegrationMetadataSchema.parse(integration.metadata); - const azureAppConfigSecrets = ( - await getCompleteAzureAppConfigValues( - `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix || ""}*` - ) - ).reduce( + + const azureAppConfigValuesUrl = `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix}*${ + metadata.azureLabel ? `&label=${metadata.azureLabel}` : "" + }`; + + const azureAppConfigSecrets = (await getCompleteAzureAppConfigValues(azureAppConfigValuesUrl)).reduce( (accum, entry) => { accum[entry.key] = entry.value; @@ -410,14 +412,24 @@ const syncSecretsAzureAppConfig = async ({ } // create or update secrets on Azure App Config + for await (const key of Object.keys(secrets)) { if (!(key in azureAppConfigSecrets) || secrets[key]?.value !== azureAppConfigSecrets[key]) { await request.put( `${integration.app}/kv/${key}?api-version=2023-11-01`, { - value: secrets[key]?.value + value: secrets[key]?.value, + ...(isAzureKeyVaultReference(secrets[key]?.value || "") && { + content_type: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" + }) }, { + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), + headers: { Authorization: `Bearer ${accessToken}` }, @@ -437,6 +449,11 @@ const syncSecretsAzureAppConfig = async ({ headers: { Authorization: `Bearer ${accessToken}` }, + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), // we force IPV4 because docker setup fails with ipv6 httpsAgent: new https.Agent({ family: 4 diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts index d047a0c11..de4790188 100644 --- a/backend/src/services/integration/integration-schema.ts +++ b/backend/src/services/integration/integration-schema.ts @@ -35,6 +35,8 @@ export const IntegrationMetadataSchema = z.object({ .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + azureLabel: z.string().optional().describe(INTEGRATION.CREATE.metadata.azureLabel), + githubVisibility: z .union([z.literal("selected"), z.literal("private"), z.literal("all")]) .optional() diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 11d42631a..5c059ae98 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -80,6 +80,7 @@ export const useCreateIntegration = () => { key: string; value: string; }[]; + azureLabel?: string; githubVisibility?: string; githubVisibilityRepoIds?: string[]; kmsKeyId?: string; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 0346b065a..7054befc7 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -41,6 +41,7 @@ export type TIntegration = { key: string; value: string; }[]; + azureLabel?: string; kmsKeyId?: string; secretSuffix?: string; diff --git a/frontend/src/pages/integrations/azure-app-configuration/create.tsx b/frontend/src/pages/integrations/azure-app-configuration/create.tsx index c9fe4d1db..9b647c2a2 100644 --- a/frontend/src/pages/integrations/azure-app-configuration/create.tsx +++ b/frontend/src/pages/integrations/azure-app-configuration/create.tsx @@ -10,6 +10,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import queryString from "query-string"; import { z } from "zod"; +import { createNotification } from "@app/components/notifications"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration } from "@app/hooks/api"; import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; @@ -19,9 +20,11 @@ import { Card, CardTitle, FormControl, + FormLabel, Input, Select, - SelectItem + SelectItem, + Switch } from "../../../components/v2"; import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; import { useGetWorkspaceById } from "../../../hooks/api/workspace"; @@ -39,7 +42,9 @@ const schema = z.object({ secretPath: z.string().trim().min(1, { message: "Secret path is required" }), sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }), initialSyncBehavior: z.nativeEnum(IntegrationSyncBehavior), - secretPrefix: z.string().default("") + secretPrefix: z.string().default(""), + useLabels: z.boolean().default(false), + azureLabel: z.string().min(1).optional() }); type TFormSchema = z.infer; @@ -60,6 +65,7 @@ export default function AzureAppConfigurationCreateIntegration() { const router = useRouter(); const { control, + watch, setValue, handleSubmit, formState: { isSubmitting } @@ -85,16 +91,28 @@ export default function AzureAppConfigurationCreateIntegration() { } }, [workspace]); + const shouldUseLabels = watch("useLabels"); + const handleIntegrationSubmit = async ({ secretPath, + useLabels, sourceEnvironment, baseUrl, initialSyncBehavior, - secretPrefix + secretPrefix, + azureLabel }: TFormSchema) => { try { if (!integrationAuth?.id) return; + if (useLabels && !azureLabel) { + createNotification({ + type: "error", + text: "Label must be provided when 'Use Labels' is enabled" + }); + return; + } + await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, @@ -103,7 +121,8 @@ export default function AzureAppConfigurationCreateIntegration() { secretPath, metadata: { initialSyncBehavior, - secretPrefix + secretPrefix, + ...(useLabels && { azureLabel }) } }); @@ -155,35 +174,70 @@ export default function AzureAppConfigurationCreateIntegration() {
- ( - - + + )} + /> + +
+ ( + onChange(isChecked)} + isChecked={value} + > + + + )} + /> + + {shouldUseLabels && ( + ( + - {sourceEnvironment.name} - - ))} - - - )} - /> + + + )} + /> + )} +
+
{ Object.entries(integration.metadata).map(([key, value]) => (

- {metadataMappings[key as keyof typeof metadataMappings]} + {!!value && metadataMappings[key as keyof typeof metadataMappings]}

{renderValue(key as MetadataKey, value)}