From c6d2683b4508e56f036bc279aeb6411d18aa7db3 Mon Sep 17 00:00:00 2001 From: Priyanka-Microsoft Date: Fri, 17 Apr 2026 12:07:02 +0530 Subject: [PATCH 1/3] Restrict public access for api endpoint --- docs/LOCAL_DEPLOYMENT.md | 2 + docs/best_practices.md | 16 ++++ infra/main.bicep | 39 ++++++++- infra/main.json | 107 ++++++++++++++++++++++-- infra/modules/app/function.bicep | 12 +++ infra/modules/core/host/functions.bicep | 12 +++ infra/modules/virtualNetwork.bicep | 55 +++++++++++- 7 files changed, 235 insertions(+), 8 deletions(-) diff --git a/docs/LOCAL_DEPLOYMENT.md b/docs/LOCAL_DEPLOYMENT.md index aa9de9016..8df8eb854 100644 --- a/docs/LOCAL_DEPLOYMENT.md +++ b/docs/LOCAL_DEPLOYMENT.md @@ -169,6 +169,8 @@ Review the configuration options below. You can customize any settings that meet |------------|-----------------------------------|----------------| | **Configuration File** | `main.parameters.json` (sandbox) | Copy `main.waf.parameters.json` to `main.parameters.json` | | **Security Controls** | Minimal (for rapid iteration) | Enhanced (production best practices) | +| **Network Access** | All services publicly accessible | Backend API (Function App) restricted to private network; only frontend publicly accessible | +| **Private Endpoints** | Disabled | Enabled for all backend services (Storage, Key Vault, Cosmos DB/PostgreSQL, OpenAI, Search, Function App) | | **Cost** | Lower costs | Cost optimized | | **Use Case** | POCs, development, testing | Production workloads | | **Framework** | Basic configuration | [Well-Architected Framework](https://learn.microsoft.com/en-us/azure/well-architected/) | diff --git a/docs/best_practices.md b/docs/best_practices.md index 9cd69b868..5e4c3c84e 100644 --- a/docs/best_practices.md +++ b/docs/best_practices.md @@ -43,6 +43,22 @@ Moreover, optimizing the data in the index also enhances the efficiency, the spe - For the best results, prepare your index data and consider [analyzers](https://learn.microsoft.com/azure/search/search-analyzers). - Analyze your [resource capacity needs](https://learn.microsoft.com/azure/search/search-capacity-planning). +**Network Security (Production/WAF Deployment)** + +When deploying with the production/WAF configuration (`enablePrivateNetworking: true`), the following network security measures are automatically applied: + +- **Private Endpoints**: All backend services including Azure OpenAI, Azure AI Search, Storage Account, Key Vault, Cosmos DB/PostgreSQL, and the Function App (backend API) are configured with private endpoints, making them accessible only through the VNet. +- **Function App (Backend API)**: The Function App hosting the backend API is secured with: + - Private endpoint for inbound traffic + - VNet integration for outbound traffic + - Public network access disabled + - Communication limited to internal VNet traffic only +- **Frontend Web Apps**: The App Service (frontend) and Admin App remain publicly accessible to serve user traffic, while communicating with backend services through the private network. +- **Virtual Network**: All resources are integrated into a secure virtual network with properly configured subnets and Network Security Groups (NSGs). +- **Bastion Host**: A jumpbox VM accessible via Azure Bastion is provided for management access to private resources. + +This architecture ensures that only the frontend applications are publicly accessible, while all backend APIs and data services remain protected within the private network boundary. + **Before deploying Azure RAG implementations to production** - Follow the best practices described in [Azure Well-Architected-Framework](https://learn.microsoft.com/azure/well-architected/). diff --git a/infra/main.bicep b/infra/main.bicep index 820418ebe..1369391f2 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -624,6 +624,7 @@ var privateDnsZones = [ 'privatelink.openai.azure.com' 'privatelink.vaultcore.azure.net' 'privatelink.api.azureml.ms' + 'privatelink.azurewebsites.net' ] // DNS Zone Index Constants @@ -638,6 +639,7 @@ var dnsZoneIndex = { openAI: 7 keyVault: 8 machinelearning: 9 + azureWebsites: 10 // 'privatelink.azurewebsites.net' } // =================================================== @@ -1433,7 +1435,42 @@ module function 'modules/app/function.bicep' = { virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : '' vnetRouteAllEnabled: enablePrivateNetworking ? true : false vnetImagePullEnabled: enablePrivateNetworking ? true : false - publicNetworkAccess: 'Enabled' // Always enabling public network access + // WAF: Use IP restrictions to block public API access while allowing SCM for deployments + // publicNetworkAccess stays Enabled, but ipSecurityRestrictions blocks public traffic to the main site + publicNetworkAccess: 'Enabled' + // Block all public access to the main site (API) - traffic must go through private endpoint + ipSecurityRestrictions: enablePrivateNetworking + ? [ + { + name: 'DenyAllPublicAccess' + description: 'Deny public access. Use private endpoint.' + action: 'Deny' + priority: 100 + ipAddress: '0.0.0.0/0' + } + ] + : [] + // SCM restrictions: Keep empty to allow deployments from any location (or restrict to specific IPs if needed) + scmIpSecurityRestrictions: [] + // Do NOT inherit main site restrictions for SCM - this allows deployments while API is private + scmIpSecurityRestrictionsUseMain: false + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${hostingModel == 'container' ? '${functionName}-docker' : functionName}' + customNetworkInterfaceName: 'nic-${hostingModel == 'container' ? '${functionName}-docker' : functionName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.azureWebsites]!.outputs.resourceId + } + ] + } + service: 'sites' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] appSettings: union( { AZURE_BLOB_ACCOUNT_NAME: storageAccountName diff --git a/infra/main.json b/infra/main.json index a326f5d12..23ad752ba 100644 --- a/infra/main.json +++ b/infra/main.json @@ -635,7 +635,8 @@ "privatelink.cognitiveservices.azure.com", "privatelink.openai.azure.com", "privatelink.vaultcore.azure.net", - "privatelink.api.azureml.ms" + "privatelink.api.azureml.ms", + "privatelink.azurewebsites.net" ], "dnsZoneIndex": { "cosmosDB": 0, @@ -647,7 +648,8 @@ "cognitiveServices": 6, "openAI": 7, "keyVault": 8, - "machinelearning": 9 + "machinelearning": 9, + "azureWebsites": 10 }, "cosmosDbName": "db_conversation_history", "cosmosDbContainerName": "conversations", @@ -700,7 +702,7 @@ "apiVersion": "2025-04-01", "name": "default", "properties": { - "tags": "[union(variables('existingTags'), variables('allTags'), createObject('TemplateName', 'CWYD', 'CreatedBy', parameters('createdBy')))]" + "tags": "[union(variables('existingTags'), variables('allTags'), createObject('TemplateName', 'CWYD', 'CreatedBy', parameters('createdBy'), 'SecurityControl', 'Ignore'))]" } }, "avmTelemetry": { @@ -1014,6 +1016,36 @@ "sourceAddressPrefix": "AzureLoadBalancer", "destinationAddressPrefix": "10.0.0.0/23" } + }, + { + "name": "AllowOutboundToPrivateEndpoints", + "properties": { + "access": "Allow", + "direction": "Outbound", + "priority": 100, + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefixes": [ + "10.0.0.0/23" + ], + "destinationAddressPrefixes": [ + "10.0.2.0/23" + ] + } + }, + { + "name": "AllowOutboundToVNet", + "properties": { + "access": "Allow", + "direction": "Outbound", + "priority": 200, + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "VirtualNetwork", + "destinationAddressPrefix": "VirtualNetwork" + } } ] }, @@ -37761,6 +37793,14 @@ "publicNetworkAccess": { "value": "Enabled" }, + "ipSecurityRestrictions": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', 'DenyAllPublicAccess', 'description', 'Deny public access. Use private endpoint.', 'action', 'Deny', 'priority', 100, 'ipAddress', '0.0.0.0/0'))), createObject('value', createArray()))]", + "scmIpSecurityRestrictions": { + "value": [] + }, + "scmIpSecurityRestrictionsUseMain": { + "value": false + }, + "privateEndpoints": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', format('pep-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'customNetworkInterfaceName', format('nic-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').azureWebsites)).outputs.resourceId.value))), 'service', 'sites', 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value))), createObject('value', createArray()))]", "appSettings": { "value": "[union(createObject('AZURE_BLOB_ACCOUNT_NAME', variables('storageAccountName'), 'AZURE_BLOB_CONTAINER_NAME', variables('blobContainerName'), 'AZURE_FORM_RECOGNIZER_ENDPOINT', reference('formrecognizer').outputs.endpoint.value, 'AZURE_COMPUTER_VISION_ENDPOINT', if(parameters('useAdvancedImageProcessing'), reference('computerVision').outputs.endpoint.value, ''), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_API_VERSION', parameters('computerVisionVectorizeImageApiVersion'), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_MODEL_VERSION', parameters('computerVisionVectorizeImageModelVersion'), 'AZURE_CONTENT_SAFETY_ENDPOINT', reference('contentsafety').outputs.endpoint.value, 'AZURE_KEY_VAULT_ENDPOINT', reference('keyvault').outputs.uri.value, 'AZURE_OPENAI_MODEL', parameters('azureOpenAIModel'), 'AZURE_OPENAI_MODEL_NAME', parameters('azureOpenAIModelName'), 'AZURE_OPENAI_MODEL_VERSION', parameters('azureOpenAIModelVersion'), 'AZURE_OPENAI_EMBEDDING_MODEL', parameters('azureOpenAIEmbeddingModel'), 'AZURE_OPENAI_EMBEDDING_MODEL_NAME', parameters('azureOpenAIEmbeddingModelName'), 'AZURE_OPENAI_EMBEDDING_MODEL_VERSION', parameters('azureOpenAIEmbeddingModelVersion'), 'AZURE_OPENAI_RESOURCE', variables('azureOpenAIResourceName'), 'AZURE_OPENAI_API_VERSION', parameters('azureOpenAIApiVersion'), 'USE_ADVANCED_IMAGE_PROCESSING', if(parameters('useAdvancedImageProcessing'), 'true', 'false'), 'DOCUMENT_PROCESSING_QUEUE_NAME', variables('queueName'), 'ORCHESTRATION_STRATEGY', parameters('orchestrationStrategy'), 'LOGLEVEL', parameters('logLevel'), 'PACKAGE_LOGGING_LEVEL', 'WARNING', 'AZURE_LOGGING_PACKAGES', '', 'AZURE_OPENAI_SYSTEM_MESSAGE', parameters('azureOpenAISystemMessage'), 'OPEN_AI_FUNCTIONS_SYSTEM_PROMPT', variables('openAIFunctionsSystemPrompt'), 'SEMANTIC_KERNEL_SYSTEM_PROMPT', variables('semanticKernelSystemPrompt'), 'DATABASE_TYPE', parameters('databaseType'), 'MANAGED_IDENTITY_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'MANAGED_IDENTITY_RESOURCE_ID', reference('managedIdentityModule').outputs.resourceId.value, 'AZURE_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'APP_ENV', parameters('appEnvironment'), 'BACKEND_URL', variables('backendUrl'), 'AZURE_SEARCH_DIMENSIONS', parameters('azureSearchDimensions')), if(equals(parameters('databaseType'), 'CosmosDB'), createObject('AZURE_SEARCH_INDEX', variables('azureSearchIndex'), 'AZURE_SEARCH_SERVICE', format('https://{0}.search.windows.net', variables('azureAISearchName')), 'AZURE_SEARCH_DATASOURCE_NAME', variables('azureSearchDatasource'), 'AZURE_SEARCH_INDEXER_NAME', variables('azureSearchIndexer'), 'AZURE_SEARCH_USE_INTEGRATED_VECTORIZATION', if(parameters('azureSearchUseIntegratedVectorization'), 'true', 'false'), 'AZURE_SEARCH_FIELDS_ID', parameters('azureSearchFieldId'), 'AZURE_SEARCH_CONTENT_COLUMN', parameters('azureSearchContentColumn'), 'AZURE_SEARCH_CONTENT_VECTOR_COLUMN', parameters('azureSearchVectorColumn'), 'AZURE_SEARCH_TITLE_COLUMN', parameters('azureSearchTitleColumn'), 'AZURE_SEARCH_FIELDS_METADATA', parameters('azureSearchFieldsMetadata'), 'AZURE_SEARCH_SOURCE_COLUMN', parameters('azureSearchSourceColumn'), 'AZURE_SEARCH_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchTextColumn'), ''), 'AZURE_SEARCH_LAYOUT_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchLayoutTextColumn'), ''), 'AZURE_SEARCH_CHUNK_COLUMN', parameters('azureSearchChunkColumn'), 'AZURE_SEARCH_OFFSET_COLUMN', parameters('azureSearchOffsetColumn'), 'AZURE_SEARCH_TOP_K', parameters('azureSearchTopK')), if(equals(parameters('databaseType'), 'PostgreSQL'), createObject('AZURE_POSTGRESQL_HOST_NAME', variables('postgresDBFqdn'), 'AZURE_POSTGRESQL_DATABASE_NAME', variables('postgresDBName'), 'AZURE_POSTGRESQL_USER', reference('managedIdentityModule').outputs.name.value), createObject())))]" } @@ -37928,6 +37968,27 @@ "metadata": { "description": "Optional. Whether or not public network access is allowed for this resource. For security reasons it should be disabled when using private endpoints." } + }, + "ipSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. IP security restrictions for the main site. Use to restrict access to specific IPs/subnets while keeping SCM accessible." + } + }, + "scmIpSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. IP security restrictions for the SCM site. Use to control deployment access separately from the main site." + } + }, + "scmIpSecurityRestrictionsUseMain": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Whether to use the same restrictions for SCM as for the main site." + } } }, "variables": { @@ -38005,7 +38066,16 @@ "diagnosticSettings": { "value": "[parameters('diagnosticSettings')]" }, - "publicNetworkAccess": "[if(empty(parameters('publicNetworkAccess')), createObject('value', null()), createObject('value', parameters('publicNetworkAccess')))]" + "publicNetworkAccess": "[if(empty(parameters('publicNetworkAccess')), createObject('value', null()), createObject('value', parameters('publicNetworkAccess')))]", + "ipSecurityRestrictions": { + "value": "[parameters('ipSecurityRestrictions')]" + }, + "scmIpSecurityRestrictions": { + "value": "[parameters('scmIpSecurityRestrictions')]" + }, + "scmIpSecurityRestrictionsUseMain": { + "value": "[parameters('scmIpSecurityRestrictionsUseMain')]" + } }, "template": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", @@ -38278,6 +38348,27 @@ "metadata": { "description": "Optional. Whether or not public network access is allowed for this resource. For security reasons it should be disabled when using private endpoints." } + }, + "ipSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. IP security restrictions for the main site. Use to restrict access to specific IPs/subnets while keeping SCM accessible." + } + }, + "scmIpSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Optional. IP security restrictions for the SCM site. Use to control deployment access separately from the main site." + } + }, + "scmIpSecurityRestrictionsUseMain": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. Whether to use the same restrictions for SCM as for the main site." + } } }, "variables": { @@ -38326,7 +38417,10 @@ }, "healthCheckPath": "[parameters('healthCheckPath')]", "minTlsVersion": "1.2", - "ftpsState": "FtpsOnly" + "ftpsState": "FtpsOnly", + "ipSecurityRestrictions": "[if(not(empty(parameters('ipSecurityRestrictions'))), parameters('ipSecurityRestrictions'), null())]", + "scmIpSecurityRestrictions": "[if(not(empty(parameters('scmIpSecurityRestrictions'))), parameters('scmIpSecurityRestrictions'), null())]", + "scmIpSecurityRestrictionsUseMain": "[parameters('scmIpSecurityRestrictionsUseMain')]" } }, "serverFarmResourceId": { @@ -40394,6 +40488,7 @@ } }, "dependsOn": [ + "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').azureWebsites)]", "computerVision", "contentsafety", "formrecognizer", @@ -56557,4 +56652,4 @@ "value": "[variables('semanticKernelSystemPrompt')]" } } -} \ No newline at end of file +} diff --git a/infra/modules/app/function.bicep b/infra/modules/app/function.bicep index 2da430976..59de8390d 100644 --- a/infra/modules/app/function.bicep +++ b/infra/modules/app/function.bicep @@ -73,6 +73,15 @@ param diagnosticSettings array = [] ]) param publicNetworkAccess string? +@description('Optional. IP security restrictions for the main site. Use to restrict access to specific IPs/subnets while keeping SCM accessible.') +param ipSecurityRestrictions array = [] + +@description('Optional. IP security restrictions for the SCM site. Use to control deployment access separately from the main site.') +param scmIpSecurityRestrictions array = [] + +@description('Optional. Whether to use the same restrictions for SCM as for the main site.') +param scmIpSecurityRestrictionsUseMain bool = false + var useDocker = !empty(dockerFullImageName) var kind = useDocker ? 'functionapp,linux,container' : 'functionapp,linux' @@ -102,6 +111,9 @@ module function '../core/host/functions.bicep' = { privateEndpoints: privateEndpoints diagnosticSettings: diagnosticSettings publicNetworkAccess: empty(publicNetworkAccess) ? null : publicNetworkAccess + ipSecurityRestrictions: ipSecurityRestrictions + scmIpSecurityRestrictions: scmIpSecurityRestrictions + scmIpSecurityRestrictionsUseMain: scmIpSecurityRestrictionsUseMain } } diff --git a/infra/modules/core/host/functions.bicep b/infra/modules/core/host/functions.bicep index 036438413..abf2df89b 100644 --- a/infra/modules/core/host/functions.bicep +++ b/infra/modules/core/host/functions.bicep @@ -134,6 +134,15 @@ param diagnosticSettings array = [] ]) param publicNetworkAccess string = 'Enabled' +@description('Optional. IP security restrictions for the main site. Use to restrict access to specific IPs/subnets while keeping SCM accessible.') +param ipSecurityRestrictions array = [] + +@description('Optional. IP security restrictions for the SCM site. Use to control deployment access separately from the main site.') +param scmIpSecurityRestrictions array = [] + +@description('Optional. Whether to use the same restrictions for SCM as for the main site.') +param scmIpSecurityRestrictionsUseMain bool = false + var appConfigs = [ { name: 'appsettings' @@ -184,6 +193,9 @@ module functions 'appservice.bicep' = { healthCheckPath: healthCheckPath minTlsVersion: '1.2' ftpsState: 'FtpsOnly' + ipSecurityRestrictions: !empty(ipSecurityRestrictions) ? ipSecurityRestrictions : null + scmIpSecurityRestrictions: !empty(scmIpSecurityRestrictions) ? scmIpSecurityRestrictions : null + scmIpSecurityRestrictionsUseMain: scmIpSecurityRestrictionsUseMain } serverFarmResourceId: serverFarmResourceId configs: appConfigs diff --git a/infra/modules/virtualNetwork.bicep b/infra/modules/virtualNetwork.bicep index 86216f820..2bdc6d339 100644 --- a/infra/modules/virtualNetwork.bicep +++ b/infra/modules/virtualNetwork.bicep @@ -57,6 +57,32 @@ param subnets subnetType[] = [ destinationAddressPrefix: '10.0.0.0/23' } } + { + name: 'AllowOutboundToPrivateEndpoints' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 100 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/23'] // Web subnet + destinationAddressPrefixes: ['10.0.2.0/23'] // Private endpoints subnet + } + } + { + name: 'AllowOutboundToVNet' + properties: { + access: 'Allow' + direction: 'Outbound' + priority: 200 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationAddressPrefix: 'VirtualNetwork' + } + } ] } delegation: 'Microsoft.Web/serverFarms' @@ -68,7 +94,34 @@ param subnets subnetType[] = [ privateLinkServiceNetworkPolicies: 'Disabled' networkSecurityGroup: { name: 'nsg-peps' - securityRules: [] + securityRules: [ + { + name: 'AllowWebSubnetInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 100 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefixes: ['10.0.0.0/23'] // Web subnet (App Services with VNet integration) + destinationAddressPrefixes: ['10.0.2.0/23'] // Private endpoints subnet + } + } + { + name: 'AllowVNetInbound' + properties: { + access: 'Allow' + direction: 'Inbound' + priority: 200 + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationAddressPrefix: 'VirtualNetwork' + } + } + ] } } { From 186371279bb56df9646d34238ccec7f918722df9 Mon Sep 17 00:00:00 2001 From: Priyanka-Microsoft Date: Fri, 24 Apr 2026 15:08:53 +0530 Subject: [PATCH 2/3] update document for code hosting --- docs/LOCAL_DEPLOYMENT.md | 6 +++++- docs/best_practices.md | 8 ++++---- infra/main.bicep | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/LOCAL_DEPLOYMENT.md b/docs/LOCAL_DEPLOYMENT.md index 8df8eb854..d0188166d 100644 --- a/docs/LOCAL_DEPLOYMENT.md +++ b/docs/LOCAL_DEPLOYMENT.md @@ -170,12 +170,16 @@ Review the configuration options below. You can customize any settings that meet | **Configuration File** | `main.parameters.json` (sandbox) | Copy `main.waf.parameters.json` to `main.parameters.json` | | **Security Controls** | Minimal (for rapid iteration) | Enhanced (production best practices) | | **Network Access** | All services publicly accessible | Backend API (Function App) restricted to private network; only frontend publicly accessible | -| **Private Endpoints** | Disabled | Enabled for all backend services (Storage, Key Vault, Cosmos DB/PostgreSQL, OpenAI, Search, Function App) | +| **Private Endpoints** | Disabled | Enabled for backend services (Storage, Key Vault, Cosmos DB/PostgreSQL, OpenAI, Search). Function App private endpoint is included for container hosting; for code hosting, keep API private access without adding a Function App private endpoint. | | **Cost** | Lower costs | Cost optimized | | **Use Case** | POCs, development, testing | Production workloads | | **Framework** | Basic configuration | [Well-Architected Framework](https://learn.microsoft.com/en-us/azure/well-architected/) | | **Features** | Core functionality | Reliability, security, operational excellence | +> **Note - WAF Deployment (Restrict API to Private Access, Function App on App Service Plan Accelerators):** +> If `AZURE_APP_SERVICE_HOSTING_MODEL` is set to `code`, do **not** implement a private endpoint for the backend API Function App. +> Keep the API restricted through App Service access restrictions/private networking controls applicable to code hosting. + **To use production configuration:** Copy the contents from the production configuration file to your main parameters file: diff --git a/docs/best_practices.md b/docs/best_practices.md index 5e4c3c84e..8278b27d7 100644 --- a/docs/best_practices.md +++ b/docs/best_practices.md @@ -47,12 +47,12 @@ Moreover, optimizing the data in the index also enhances the efficiency, the spe When deploying with the production/WAF configuration (`enablePrivateNetworking: true`), the following network security measures are automatically applied: -- **Private Endpoints**: All backend services including Azure OpenAI, Azure AI Search, Storage Account, Key Vault, Cosmos DB/PostgreSQL, and the Function App (backend API) are configured with private endpoints, making them accessible only through the VNet. +- **Private Endpoints**: Backend services including Azure OpenAI, Azure AI Search, Storage Account, Key Vault, and Cosmos DB/PostgreSQL are configured with private endpoints. For the Function App (backend API), private endpoint is used in container hosting; in code hosting, follow access restrictions/private networking controls without adding a Function App private endpoint. - **Function App (Backend API)**: The Function App hosting the backend API is secured with: - - Private endpoint for inbound traffic + - Private endpoint for inbound traffic in container hosting - VNet integration for outbound traffic - - Public network access disabled - - Communication limited to internal VNet traffic only + - Public inbound access blocked using App Service access restrictions + - Communication limited to approved private paths and network controls - **Frontend Web Apps**: The App Service (frontend) and Admin App remain publicly accessible to serve user traffic, while communicating with backend services through the private network. - **Virtual Network**: All resources are integrated into a secure virtual network with properly configured subnets and Network Security Groups (NSGs). - **Bastion Host**: A jumpbox VM accessible via Azure Bastion is provided for management access to private resources. diff --git a/infra/main.bicep b/infra/main.bicep index 1369391f2..165f63d97 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1438,12 +1438,12 @@ module function 'modules/app/function.bicep' = { // WAF: Use IP restrictions to block public API access while allowing SCM for deployments // publicNetworkAccess stays Enabled, but ipSecurityRestrictions blocks public traffic to the main site publicNetworkAccess: 'Enabled' - // Block all public access to the main site (API) - traffic must go through private endpoint + // Block all public access to the main site (API) ipSecurityRestrictions: enablePrivateNetworking ? [ { name: 'DenyAllPublicAccess' - description: 'Deny public access. Use private endpoint.' + description: 'Deny public access to API endpoint.' action: 'Deny' priority: 100 ipAddress: '0.0.0.0/0' @@ -1454,7 +1454,7 @@ module function 'modules/app/function.bicep' = { scmIpSecurityRestrictions: [] // Do NOT inherit main site restrictions for SCM - this allows deployments while API is private scmIpSecurityRestrictionsUseMain: false - privateEndpoints: enablePrivateNetworking + privateEndpoints: (enablePrivateNetworking && hostingModel == 'container') ? [ { name: 'pep-${hostingModel == 'container' ? '${functionName}-docker' : functionName}' From b26b8d57d6bd8dbd8a910d3e4b4ce155885119d0 Mon Sep 17 00:00:00 2001 From: Priyanka-Microsoft Date: Tue, 28 Apr 2026 08:16:43 +0000 Subject: [PATCH 3/3] bicep changes --- infra/main.bicep | 2 +- infra/main.json | 51 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 165f63d97..c2a10b1fe 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1439,7 +1439,7 @@ module function 'modules/app/function.bicep' = { // publicNetworkAccess stays Enabled, but ipSecurityRestrictions blocks public traffic to the main site publicNetworkAccess: 'Enabled' // Block all public access to the main site (API) - ipSecurityRestrictions: enablePrivateNetworking + ipSecurityRestrictions: (enablePrivateNetworking && hostingModel == 'container') ? [ { name: 'DenyAllPublicAccess' diff --git a/infra/main.json b/infra/main.json index 23ad752ba..e737a45c9 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.42.1.51946", - "templateHash": "7917528723591220424" + "templateHash": "11227085249942350248" } }, "parameters": { @@ -702,7 +702,7 @@ "apiVersion": "2025-04-01", "name": "default", "properties": { - "tags": "[union(variables('existingTags'), variables('allTags'), createObject('TemplateName', 'CWYD', 'CreatedBy', parameters('createdBy'), 'SecurityControl', 'Ignore'))]" + "tags": "[union(variables('existingTags'), variables('allTags'), createObject('TemplateName', 'CWYD', 'CreatedBy', parameters('createdBy')))]" } }, "avmTelemetry": { @@ -776,7 +776,7 @@ "_generator": { "name": "bicep", "version": "0.42.1.51946", - "templateHash": "10505014921320408169" + "templateHash": "9098946313286116864" } }, "definitions": { @@ -1060,7 +1060,38 @@ "privateLinkServiceNetworkPolicies": "Disabled", "networkSecurityGroup": { "name": "nsg-peps", - "securityRules": [] + "securityRules": [ + { + "name": "AllowWebSubnetInbound", + "properties": { + "access": "Allow", + "direction": "Inbound", + "priority": 100, + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefixes": [ + "10.0.0.0/23" + ], + "destinationAddressPrefixes": [ + "10.0.2.0/23" + ] + } + }, + { + "name": "AllowVNetInbound", + "properties": { + "access": "Allow", + "direction": "Inbound", + "priority": 200, + "protocol": "*", + "sourcePortRange": "*", + "destinationPortRange": "*", + "sourceAddressPrefix": "VirtualNetwork", + "destinationAddressPrefix": "VirtualNetwork" + } + } + ] } }, { @@ -37793,14 +37824,14 @@ "publicNetworkAccess": { "value": "Enabled" }, - "ipSecurityRestrictions": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', 'DenyAllPublicAccess', 'description', 'Deny public access. Use private endpoint.', 'action', 'Deny', 'priority', 100, 'ipAddress', '0.0.0.0/0'))), createObject('value', createArray()))]", + "ipSecurityRestrictions": "[if(and(parameters('enablePrivateNetworking'), equals(parameters('hostingModel'), 'container')), createObject('value', createArray(createObject('name', 'DenyAllPublicAccess', 'description', 'Deny public access to API endpoint.', 'action', 'Deny', 'priority', 100, 'ipAddress', '0.0.0.0/0'))), createObject('value', createArray()))]", "scmIpSecurityRestrictions": { "value": [] }, "scmIpSecurityRestrictionsUseMain": { "value": false }, - "privateEndpoints": "[if(parameters('enablePrivateNetworking'), createObject('value', createArray(createObject('name', format('pep-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'customNetworkInterfaceName', format('nic-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').azureWebsites)).outputs.resourceId.value))), 'service', 'sites', 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value))), createObject('value', createArray()))]", + "privateEndpoints": "[if(and(parameters('enablePrivateNetworking'), equals(parameters('hostingModel'), 'container')), createObject('value', createArray(createObject('name', format('pep-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'customNetworkInterfaceName', format('nic-{0}', if(equals(parameters('hostingModel'), 'container'), format('{0}-docker', variables('functionName')), variables('functionName'))), 'privateDnsZoneGroup', createObject('privateDnsZoneGroupConfigs', createArray(createObject('privateDnsZoneResourceId', reference(format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').azureWebsites)).outputs.resourceId.value))), 'service', 'sites', 'subnetResourceId', reference('virtualNetwork').outputs.pepsSubnetResourceId.value))), createObject('value', createArray()))]", "appSettings": { "value": "[union(createObject('AZURE_BLOB_ACCOUNT_NAME', variables('storageAccountName'), 'AZURE_BLOB_CONTAINER_NAME', variables('blobContainerName'), 'AZURE_FORM_RECOGNIZER_ENDPOINT', reference('formrecognizer').outputs.endpoint.value, 'AZURE_COMPUTER_VISION_ENDPOINT', if(parameters('useAdvancedImageProcessing'), reference('computerVision').outputs.endpoint.value, ''), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_API_VERSION', parameters('computerVisionVectorizeImageApiVersion'), 'AZURE_COMPUTER_VISION_VECTORIZE_IMAGE_MODEL_VERSION', parameters('computerVisionVectorizeImageModelVersion'), 'AZURE_CONTENT_SAFETY_ENDPOINT', reference('contentsafety').outputs.endpoint.value, 'AZURE_KEY_VAULT_ENDPOINT', reference('keyvault').outputs.uri.value, 'AZURE_OPENAI_MODEL', parameters('azureOpenAIModel'), 'AZURE_OPENAI_MODEL_NAME', parameters('azureOpenAIModelName'), 'AZURE_OPENAI_MODEL_VERSION', parameters('azureOpenAIModelVersion'), 'AZURE_OPENAI_EMBEDDING_MODEL', parameters('azureOpenAIEmbeddingModel'), 'AZURE_OPENAI_EMBEDDING_MODEL_NAME', parameters('azureOpenAIEmbeddingModelName'), 'AZURE_OPENAI_EMBEDDING_MODEL_VERSION', parameters('azureOpenAIEmbeddingModelVersion'), 'AZURE_OPENAI_RESOURCE', variables('azureOpenAIResourceName'), 'AZURE_OPENAI_API_VERSION', parameters('azureOpenAIApiVersion'), 'USE_ADVANCED_IMAGE_PROCESSING', if(parameters('useAdvancedImageProcessing'), 'true', 'false'), 'DOCUMENT_PROCESSING_QUEUE_NAME', variables('queueName'), 'ORCHESTRATION_STRATEGY', parameters('orchestrationStrategy'), 'LOGLEVEL', parameters('logLevel'), 'PACKAGE_LOGGING_LEVEL', 'WARNING', 'AZURE_LOGGING_PACKAGES', '', 'AZURE_OPENAI_SYSTEM_MESSAGE', parameters('azureOpenAISystemMessage'), 'OPEN_AI_FUNCTIONS_SYSTEM_PROMPT', variables('openAIFunctionsSystemPrompt'), 'SEMANTIC_KERNEL_SYSTEM_PROMPT', variables('semanticKernelSystemPrompt'), 'DATABASE_TYPE', parameters('databaseType'), 'MANAGED_IDENTITY_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'MANAGED_IDENTITY_RESOURCE_ID', reference('managedIdentityModule').outputs.resourceId.value, 'AZURE_CLIENT_ID', reference('managedIdentityModule').outputs.clientId.value, 'APP_ENV', parameters('appEnvironment'), 'BACKEND_URL', variables('backendUrl'), 'AZURE_SEARCH_DIMENSIONS', parameters('azureSearchDimensions')), if(equals(parameters('databaseType'), 'CosmosDB'), createObject('AZURE_SEARCH_INDEX', variables('azureSearchIndex'), 'AZURE_SEARCH_SERVICE', format('https://{0}.search.windows.net', variables('azureAISearchName')), 'AZURE_SEARCH_DATASOURCE_NAME', variables('azureSearchDatasource'), 'AZURE_SEARCH_INDEXER_NAME', variables('azureSearchIndexer'), 'AZURE_SEARCH_USE_INTEGRATED_VECTORIZATION', if(parameters('azureSearchUseIntegratedVectorization'), 'true', 'false'), 'AZURE_SEARCH_FIELDS_ID', parameters('azureSearchFieldId'), 'AZURE_SEARCH_CONTENT_COLUMN', parameters('azureSearchContentColumn'), 'AZURE_SEARCH_CONTENT_VECTOR_COLUMN', parameters('azureSearchVectorColumn'), 'AZURE_SEARCH_TITLE_COLUMN', parameters('azureSearchTitleColumn'), 'AZURE_SEARCH_FIELDS_METADATA', parameters('azureSearchFieldsMetadata'), 'AZURE_SEARCH_SOURCE_COLUMN', parameters('azureSearchSourceColumn'), 'AZURE_SEARCH_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchTextColumn'), ''), 'AZURE_SEARCH_LAYOUT_TEXT_COLUMN', if(parameters('azureSearchUseIntegratedVectorization'), parameters('azureSearchLayoutTextColumn'), ''), 'AZURE_SEARCH_CHUNK_COLUMN', parameters('azureSearchChunkColumn'), 'AZURE_SEARCH_OFFSET_COLUMN', parameters('azureSearchOffsetColumn'), 'AZURE_SEARCH_TOP_K', parameters('azureSearchTopK')), if(equals(parameters('databaseType'), 'PostgreSQL'), createObject('AZURE_POSTGRESQL_HOST_NAME', variables('postgresDBFqdn'), 'AZURE_POSTGRESQL_DATABASE_NAME', variables('postgresDBName'), 'AZURE_POSTGRESQL_USER', reference('managedIdentityModule').outputs.name.value), createObject())))]" } @@ -37813,7 +37844,7 @@ "_generator": { "name": "bicep", "version": "0.42.1.51946", - "templateHash": "579857106859534555" + "templateHash": "12424573526045346163" } }, "parameters": { @@ -38084,7 +38115,7 @@ "_generator": { "name": "bicep", "version": "0.42.1.51946", - "templateHash": "17498839439506952033" + "templateHash": "14045360282106151326" } }, "parameters": { @@ -54619,8 +54650,8 @@ } }, "dependsOn": [ - "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)]", "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageBlob)]", + "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageQueue)]", "[format('avmPrivateDnsZones[{0}]', variables('dnsZoneIndex').storageFile)]", "managedIdentityModule", "virtualNetwork" @@ -56652,4 +56683,4 @@ "value": "[variables('semanticKernelSystemPrompt')]" } } -} +} \ No newline at end of file