From 6bb94a02069c8b1ca2a687a4ed15437f1b348f8f Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Thu, 23 Apr 2026 01:48:35 +0530 Subject: [PATCH 1/2] Harden WAF deployment: private backend ingress and frontend proxy routing --- docs/DeploymentGuide.md | 7 ++++ docs/TechnicalArchitecture.md | 2 ++ infra/main.bicep | 39 +++++++++++++++++++++-- infra/main_custom.bicep | 39 +++++++++++++++++++++-- src/ContentProcessorWeb/env.sh | 1 + src/ContentProcessorWeb/nginx-custom.conf | 12 +++++++ 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 94c3d2f3..05e838a5 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -194,6 +194,13 @@ Review the configuration options below. You can customize any settings that meet | **Framework** | Basic configuration | [Well-Architected Framework](https://learn.microsoft.com/en-us/azure/well-architected/) | | **Features** | Core functionality | Reliability, security, operational excellence | +When using the Production/WAF deployment (`enablePrivateNetworking=true`), networking is configured as follows: + +- Backend Container App endpoints are internal-only (`ingressExternal=false`) and not publicly reachable. +- Container Apps Environment is deployed in internal mode with VNet integration. +- The web frontend remains public and routes browser API traffic through same-origin `/api` proxying to the private backend over VNet. +- Private DNS is configured for the internal Container Apps Environment domain. + **To use production configuration:** Copy the contents from the production configuration file to your main parameters file: diff --git a/docs/TechnicalArchitecture.md b/docs/TechnicalArchitecture.md index dce44b65..f2005e29 100644 --- a/docs/TechnicalArchitecture.md +++ b/docs/TechnicalArchitecture.md @@ -177,6 +177,8 @@ The API also provides schema management, schema set (collection) management, and ### Claim Process Monitor Web Using Azure Container App, this app acts as the UI for the process monitoring queue. The app is built with React and TypeScript. It acts as an API client to create an experience for uploading new documents, creating and managing claim batches, monitoring current and historical processes, and reviewing output results including summarization and gap analysis. +In WAF/private networking deployments (`enablePrivateNetworking=true`), the frontend remains public while backend APIs are internal-only. The web container proxies `/api/*` traffic to the private API Container App over VNet so backend endpoints are not directly exposed to the public internet. + ### App Configuration Using Azure App Configuration, app settings and configurations are centralized and used with the Content Processor, Content Process API, Content Process Workflow, and Claim Process Monitor Web. diff --git a/infra/main.bicep b/infra/main.bicep index 1c815935..276a68bc 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -931,7 +931,8 @@ module avmContainerAppEnv 'br/public:avm/res/app/managed-environment:0.13.2' = { } ] enableTelemetry: enableTelemetry - publicNetworkAccess: 'Enabled' // Always enabled for Container Apps Environment + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + internal: enablePrivateNetworking ? true : false // <========== WAF related parameters @@ -944,6 +945,34 @@ module avmContainerAppEnv 'br/public:avm/res/app/managed-environment:0.13.2' = { } } +// ========== Private DNS Zone for internal Container App Environment ========== // +// When the CAE is internal, its FQDN is resolvable only within the VNet via this zone. +module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.0' = if (enablePrivateNetworking) { + name: take('avm.res.network.private-dns-zone.cae.${solutionSuffix}', 64) + params: { + name: avmContainerAppEnv.outputs.defaultDomain + tags: tags + enableTelemetry: enableTelemetry + a: [ + { + name: '*' + aRecords: [ + { + ipv4Address: avmContainerAppEnv.outputs.staticIp + } + ] + ttl: 300 + } + ] + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetworkResourceName}-cae', 64) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } +} + // //=========== Managed Identity for Container Registry ========== // module avmContainerRegistryReader 'br/public:avm/res/managed-identity/user-assigned-identity:0.5.0' = { name: take('avm.res.managed-identity.user-assigned-identity.${solutionSuffix}', 64) @@ -1132,7 +1161,7 @@ module avmContainerApp_API 'br/public:avm/res/app/container-app:0.22.1' = { } ] } - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true activeRevisionsMode: 'Single' ingressTransport: 'auto' corsPolicy: { @@ -1201,6 +1230,10 @@ module avmContainerApp_Web 'br/public:avm/res/app/container-app:0.22.1' = { env: [ { name: 'APP_API_BASE_URL' + value: enablePrivateNetworking ? '/api' : 'https://${avmContainerApp_API.outputs.fqdn}' + } + { + name: 'APP_BACKEND_API_URL' value: 'https://${avmContainerApp_API.outputs.fqdn}' } { @@ -1808,7 +1841,7 @@ module avmContainerApp_API_update 'br/public:avm/res/app/container-app:0.22.1' = } ] } - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true activeRevisionsMode: 'Single' ingressTransport: 'auto' corsPolicy: { diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 7696c022..25287019 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -934,7 +934,8 @@ module avmContainerAppEnv 'br/public:avm/res/app/managed-environment:0.13.2' = { } ] enableTelemetry: enableTelemetry - publicNetworkAccess: 'Enabled' // Always enabled for Container Apps Environment + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + internal: enablePrivateNetworking ? true : false // <========== WAF related parameters @@ -947,6 +948,34 @@ module avmContainerAppEnv 'br/public:avm/res/app/managed-environment:0.13.2' = { } } +// ========== Private DNS Zone for internal Container App Environment ========== // +// When the CAE is internal, its FQDN is resolvable only within the VNet via this zone. +module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.0' = if (enablePrivateNetworking) { + name: take('avm.res.network.private-dns-zone.cae.${solutionSuffix}', 64) + params: { + name: avmContainerAppEnv.outputs.defaultDomain + tags: tags + enableTelemetry: enableTelemetry + a: [ + { + name: '*' + aRecords: [ + { + ipv4Address: avmContainerAppEnv.outputs.staticIp + } + ] + ttl: 300 + } + ] + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetworkResourceName}-cae', 64) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } +} + // //=========== Managed Identity for Container Registry ========== // module avmContainerRegistryReader 'br/public:avm/res/managed-identity/user-assigned-identity:0.5.0' = { name: take('avm.res.managed-identity.user-assigned-identity.${solutionSuffix}', 64) @@ -1145,7 +1174,7 @@ module avmContainerApp_API 'br/public:avm/res/app/container-app:0.22.1' = { } ] } - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true activeRevisionsMode: 'Single' ingressTransport: 'auto' corsPolicy: { @@ -1219,6 +1248,10 @@ module avmContainerApp_Web 'br/public:avm/res/app/container-app:0.22.1' = { env: [ { name: 'APP_API_BASE_URL' + value: enablePrivateNetworking ? '/api' : 'https://${avmContainerApp_API.outputs.fqdn}' + } + { + name: 'APP_BACKEND_API_URL' value: 'https://${avmContainerApp_API.outputs.fqdn}' } { @@ -1841,7 +1874,7 @@ module avmContainerApp_API_update 'br/public:avm/res/app/container-app:0.22.1' = } ] } - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true activeRevisionsMode: 'Single' ingressTransport: 'auto' corsPolicy: { diff --git a/src/ContentProcessorWeb/env.sh b/src/ContentProcessorWeb/env.sh index 8346ce2b..31b2a989 100644 --- a/src/ContentProcessorWeb/env.sh +++ b/src/ContentProcessorWeb/env.sh @@ -6,5 +6,6 @@ do echo $key=$value # Use sed to replace only the exact matches of the key find /usr/share/nginx/html -type f -exec sed -i "s|\b${key}\b|${value}|g" '{}' + + sed -i "s|\b${key}\b|${value}|g" /etc/nginx/nginx.conf done echo 'done' \ No newline at end of file diff --git a/src/ContentProcessorWeb/nginx-custom.conf b/src/ContentProcessorWeb/nginx-custom.conf index 1ff688eb..1980c18e 100644 --- a/src/ContentProcessorWeb/nginx-custom.conf +++ b/src/ContentProcessorWeb/nginx-custom.conf @@ -18,6 +18,18 @@ http { listen 3000; server_name localhost; + # Route browser API calls through the web container so private backend + # endpoints remain internal-only in WAF/private networking deployments. + location /api/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_pass APP_BACKEND_API_URL/; + } + location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; From 1fd18c7fee5b69774327c2fc0e6ace2d221ef1d2 Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Thu, 23 Apr 2026 11:52:37 +0530 Subject: [PATCH 2/2] Update infra Bicep templates and parameters --- infra/main.bicep | 2 +- infra/main.parameters.json | 18 ++++++++++++++++++ infra/main_custom.bicep | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 276a68bc..8edaf0f4 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -966,7 +966,7 @@ module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.0' = if (enabl ] virtualNetworkLinks: [ { - name: take('vnetlink-${virtualNetworkResourceName}-cae', 64) + name: take('vnetlink-vnet-${solutionSuffix}-cae', 64) virtualNetworkResourceId: virtualNetwork!.outputs.resourceId } ] diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 27461ece..7fdeab31 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -32,6 +32,24 @@ "existingFoundryProjectResourceId": { "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" }, + "enableMonitoring": { + "value": true + }, + "enablePrivateNetworking": { + "value": true + }, + "enableScalability": { + "value": true + }, + "vmAdminUsername": { + "value": "${AZURE_ENV_VM_ADMIN_USERNAME}" + }, + "vmAdminPassword": { + "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}" + }, + "vmSize": { + "value": "${AZURE_ENV_VM_SIZE}" + }, "containerRegistryEndpoint": { "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT}" }, diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 25287019..343e860c 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -969,7 +969,7 @@ module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.0' = if (enabl ] virtualNetworkLinks: [ { - name: take('vnetlink-${virtualNetworkResourceName}-cae', 64) + name: take('vnetlink-vnet-${solutionSuffix}-cae', 64) virtualNetworkResourceId: virtualNetwork!.outputs.resourceId } ]