From d9c5f6889c381d8148c019ace292b7f87530ed3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:30:38 +0000 Subject: [PATCH 1/2] Initial plan From 6ff11fcb32830b024dbc579a6f3af147519c388b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:41:30 +0000 Subject: [PATCH 2/2] Add outbound access control policy assignment to Data Factory Assigns the built-in Azure Policy '3d02a511-74e5-4dab-a5fd-878704d4a61a' ('[Preview]: Azure Data Factory pipelines should only communicate with allowed domains') to prevent data exfiltration by restricting ADF pipeline outbound traffic to the storage accounts referenced in pipelines (private, public, and airlock) plus the Key Vault used for linked service connection strings. - adf.bicep: Add allowedStorageAccountNames parameter, compute allowed domain names (blob/dfs/file endpoints per account + KV FQDN), create policy assignment resource with Deny effect - main.bicep: Pass public and airlock storage account names to adfModule; add airlockStorageAccountNameForPolicy variable to avoid circular dependency with centralizedModule Agent-Logs-Url: https://github.com/Azure/HubAndSpokeResearchEnclave/sessions/58fbf88c-7835-43e4-9cc1-37f48d77fd6c Co-authored-by: SvenAelterman <17446043+SvenAelterman@users.noreply.github.com> --- .../spoke-modules/airlock/adf.bicep | 36 +++++++++++++++++++ .../spoke-modules/airlock/main.bicep | 14 ++++++++ 2 files changed, 50 insertions(+) diff --git a/research-spoke/spoke-modules/airlock/adf.bicep b/research-spoke/spoke-modules/airlock/adf.bicep index 5b715b8..8d226d0 100644 --- a/research-spoke/spoke-modules/airlock/adf.bicep +++ b/research-spoke/spoke-modules/airlock/adf.bicep @@ -29,6 +29,9 @@ param pipelineNameFilesToFiles string = 'pipe-data_move-files_to_files' param debugMode bool +@description('The names of additional storage accounts (beyond the private storage account) that the Data Factory is allowed to access via its pipelines. Used to configure outbound access control.') +param allowedStorageAccountNames array = [] + import { pipelineNamesType } from '../../../shared-modules/types/pipelineNamesType.bicep' var baseName = !empty(subWorkloadName) @@ -41,6 +44,20 @@ var adlsGen2LinkedServiceName = 'ls_ADLSGen2_Generic' var azFilesLinkedServiceName = 'ls_AzFiles_Generic' var kvLinkedServiceName = 'ls_KeyVault' +// Outbound access control: compute the list of allowed domain names for the Azure Policy assignment +var storageSuffix = environment().suffixes.storage +var keyVaultSuffix = environment().suffixes.keyvaultDns +// Combine the private storage account with any additional allowed storage accounts +var allStorageAccountNames = union([privateStorageAcctName], allowedStorageAccountNames) +// Generate blob, dfs, and file endpoints for each storage account +var storageAllowedDomains = flatten(map(allStorageAccountNames, name => [ + '${name}.blob.${storageSuffix}' + '${name}.dfs.${storageSuffix}' + '${name}.file.${storageSuffix}' +])) +// Include the Key Vault used by the Key Vault linked service to retrieve connection strings +var allowedDomains = concat(storageAllowedDomains, ['${keyVaultName}${keyVaultSuffix}']) + resource privateStorageAcct 'Microsoft.Storage/storageAccounts@2021-08-01' existing = { name: privateStorageAcctName } @@ -511,6 +528,25 @@ resource adfPrivateStgRole 'Microsoft.Authorization/roleAssignments@2022-04-01' } } +// Assign the built-in Azure Policy to restrict ADF outbound access to allowed domains only. +// This prevents data exfiltration by blocking pipeline communication with unapproved destinations. +// Policy: "[Preview]: Azure Data Factory pipelines should only communicate with allowed domains" +resource adfOutboundPolicyAssignment 'Microsoft.Authorization/policyAssignments@2022-06-01' = { + name: take(replace(baseName, '{rtype}', 'pa-adf-out'), 64) + scope: resourceGroup() + properties: { + policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/3d02a511-74e5-4dab-a5fd-878704d4a61a' + parameters: { + effect: { + value: 'Deny' + } + allowedDomainNames: { + value: allowedDomains + } + } + } +} + output principalId string = adf.identity.principalId output name string = adf.name diff --git a/research-spoke/spoke-modules/airlock/main.bicep b/research-spoke/spoke-modules/airlock/main.bicep index ead0291..b0c1fbe 100644 --- a/research-spoke/spoke-modules/airlock/main.bicep +++ b/research-spoke/spoke-modules/airlock/main.bicep @@ -250,12 +250,26 @@ module adfModule 'adf.bicep' = { usePrivateEndpoint: usePrivateEndpoints privateEndpointSubnetId: usePrivateEndpoints ? privateEndpointSubnetId : '' privateDnsZonesResourceGroupId: usePrivateEndpoints ? privateDnsZonesResourceGroupId : '' + + // Storage accounts the ADF is allowed to access (for outbound access control policy) + allowedStorageAccountNames: [ + publicStorageAccountNameModule.outputs.validName + airlockStorageAccountNameForPolicy + ] } } var airlockStorageAccountName = useCentralizedReview ? centralizedModule.outputs.centralAirlockStorageAccountName : spokeAirlockStorageAccountModule.outputs.storageAccountName + +// Derive the airlock storage account name without taking a dependency on centralizedModule +// (which itself depends on adfModule, which would create a circular dependency). +// For centralized review, extract the name from the resource ID parameter. +// For non-centralized review, use the spoke airlock storage account module output. +var airlockStorageAccountNameForPolicy = useCentralizedReview + ? last(split(centralAirlockResources.storageAccountId, '/')) + : spokeAirlockStorageAccountModule.outputs.storageAccountName var airlocKeyVaultUri = useCentralizedReview ? centralizedModule.outputs.centralKeyVaultUri : keyVault.properties.vaultUri