From 9440357eeed45de51d05ecfba129a5963f25c698 Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Sun, 4 Oct 2026 18:10:22 -1000 Subject: [PATCH] Allow the Front Door profile and DNS zone to live in other subscriptions Shared edge and DNS resources often sit in their own subscriptions, and the template could only reach a profile and zone in DevBrain's own subscription. - Add optional FRONT_DOOR_SUBSCRIPTION_ID and DNS_ZONE_SUBSCRIPTION_ID azd values (frontDoorSubscriptionId, dnsZoneSubscriptionId). The Front Door subscription defaults to DevBrain's, and the DNS zone's defaults to the Front Door profile's, matching how the resource groups already default. - Document the cross-subscription values and the cutover steps for moving an existing deployment behind Front Door. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 6 ++++++ infra/main.bicep | 16 +++++++++++++--- infra/main.parameters.json | 6 ++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 754173d..fd8a588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman - Added an opt-in `COSMOS_SERVERLESS` azd value (`cosmosServerless` Bicep parameter) that creates the Cosmos DB account in serverless mode. It defaults to `false`, so existing and new provisioned deployments are unchanged. - Added an optional `CUSTOM_DOMAIN_NAME` that sets the OAuth issuer, protected-resource URL, redirect URI, and `AllowedHosts` from a public host name, replacing the manual `main.bicep` edits the README previously described. -- Added optional publishing through an existing Azure Front Door Standard/Premium profile (`FRONT_DOOR_PROFILE_NAME`, `FRONT_DOOR_RESOURCE_GROUP`, `FRONT_DOOR_SKU`), with a dedicated endpoint, route, managed-certificate custom domain, and custom-rule WAF policy. The WAF includes a path allow list, per-IP rate limits for the OAuth endpoints and overall traffic, and an optional country allow list (`FRONT_DOOR_ALLOWED_COUNTRIES`). With `DNS_ZONE_NAME`, provisioning also writes the CNAME and domain-validation TXT records into an existing Azure DNS zone. +- Added optional publishing through an existing Azure Front Door Standard/Premium profile (`FRONT_DOOR_PROFILE_NAME`, `FRONT_DOOR_RESOURCE_GROUP`, `FRONT_DOOR_SKU`), with a dedicated endpoint, route, managed-certificate custom domain, and custom-rule WAF policy. The WAF includes a path allow list, per-IP rate limits for the OAuth endpoints and overall traffic, and an optional country allow list (`FRONT_DOOR_ALLOWED_COUNTRIES`). With `DNS_ZONE_NAME`, provisioning also writes the CNAME and domain-validation TXT records into an existing Azure DNS zone. The profile and the zone can each live in another subscription of the same tenant (`FRONT_DOOR_SUBSCRIPTION_ID`, `DNS_ZONE_SUBSCRIPTION_ID`). - Added an opt-in `FrontDoor:Id` origin check. When it's set, the server rejects requests that lack the matching `X-Azure-FDID` header, except `/healthz`, so the Container Apps host name can't bypass the Front Door WAF. Provisioning sets it automatically when Front Door is enabled. - Added `DEVBRAIN_PUBLIC_URL` and `DEVBRAIN_MCP_URL` provisioning outputs. diff --git a/README.md b/README.md index 525ad37..fbbd820 100644 --- a/README.md +++ b/README.md @@ -192,15 +192,21 @@ DevBrain can publish through an Azure Front Door Standard or Premium profile you azd env set CUSTOM_DOMAIN_NAME devbrain.contoso.com azd env set FRONT_DOOR_PROFILE_NAME azd env set FRONT_DOOR_RESOURCE_GROUP +azd env set FRONT_DOOR_SUBSCRIPTION_ID # only when the profile is in another subscription azd env set DNS_ZONE_NAME contoso.com # optional: Azure DNS zone that hosts the domain azd env set DNS_ZONE_RESOURCE_GROUP +azd env set DNS_ZONE_SUBSCRIPTION_ID # only when the zone is in another subscription azd env set FRONT_DOOR_ALLOWED_COUNTRIES US # optional: comma-separated country allow list azd env set FRONT_DOOR_SKU Premium_AzureFrontDoor # only for a Premium profile azd provision ``` +The Front Door profile and DNS zone can be in other subscriptions of the same tenant. The Front Door resource group and subscription default to DevBrain's own, and the DNS zone's default to the Front Door profile's. The identity that runs `azd provision` needs to be able to deploy into each of those resource groups. + `CUSTOM_DOMAIN_NAME` becomes the OAuth issuer, the protected-resource URL, and the redirect URI, so register the new `OAUTH_REDIRECT_URI` on the Entra app (step 4). With `DNS_ZONE_NAME` set, provisioning creates the CNAME and the `_dnsauth` validation TXT record. Without it, create both records yourself, using the `FRONT_DOOR_ENDPOINT_HOST_NAME` and `FRONT_DOOR_DOMAIN_VALIDATION_TOKEN` outputs. Front Door issues the managed certificate after it validates the domain, which can take several minutes. +To move an existing deployment behind Front Door, add the new redirect URI to the Entra app before you provision, and keep the old one until every client has moved. As soon as provisioning finishes, the Container Apps URL rejects MCP traffic, so point each client at `DEVBRAIN_MCP_URL` and sign in again. Remove the old redirect URI once nothing uses it. + The WAF policy uses custom rules only, so it works on the Standard tier: | Rule | Behavior | diff --git a/infra/main.bicep b/infra/main.bicep index 8992f47..beee807 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -58,6 +58,9 @@ param frontDoorProfileName string = '' @description('Resource group of the Front Door profile. Defaults to this deployment\'s resource group.') param frontDoorResourceGroupName string = '' +@description('Subscription ID of the Front Door profile. Defaults to this deployment\'s subscription.') +param frontDoorSubscriptionId string = '' + @description('SKU of the existing Front Door profile; the DevBrain WAF policy is created with the matching SKU.') @allowed([ 'Standard_AzureFrontDoor' @@ -71,6 +74,9 @@ param dnsZoneName string = '' @description('Resource group of the Azure DNS zone. Defaults to the Front Door resource group.') param dnsZoneResourceGroupName string = '' +@description('Subscription ID of the Azure DNS zone. Defaults to the Front Door subscription.') +param dnsZoneSubscriptionId string = '' + @description('Optional comma-separated two-letter country codes the Front Door WAF allows, for example "US,CA". Empty allows every country.') param frontDoorAllowedCountryCodes string = '' @@ -333,30 +339,34 @@ var containerAppBaseUrl = 'https://${containerAppHostName}' var publicBaseUrl = empty(customDomainName) ? containerAppBaseUrl : 'https://${customDomainName}' var allowedHosts = empty(customDomainName) ? containerAppHostName : '${customDomainName};${containerAppHostName}' +// The Front Door profile and DNS zone may live in other subscriptions of the same tenant, for example a +// shared edge subscription and a shared DNS subscription. var useFrontDoor = !empty(frontDoorProfileName) && !empty(customDomainName) +var frontDoorSubscription = empty(frontDoorSubscriptionId) ? subscription().subscriptionId : frontDoorSubscriptionId var frontDoorResourceGroup = empty(frontDoorResourceGroupName) ? resourceGroup().name : frontDoorResourceGroupName var useFrontDoorDns = useFrontDoor && !empty(dnsZoneName) +var dnsZoneSubscription = empty(dnsZoneSubscriptionId) ? frontDoorSubscription : dnsZoneSubscriptionId var dnsZoneResourceGroup = empty(dnsZoneResourceGroupName) ? frontDoorResourceGroup : dnsZoneResourceGroupName var normalizedDnsZoneName = toLower(dnsZoneName) var normalizedCustomDomainName = toLower(customDomainName) module frontDoor 'modules/front-door.bicep' = if (useFrontDoor) { name: 'devbrain-front-door-${substring(resourceToken, 0, 6)}' - scope: resourceGroup(frontDoorResourceGroup) + scope: resourceGroup(frontDoorSubscription, frontDoorResourceGroup) params: { profileName: frontDoorProfileName skuName: frontDoorSkuName resourceToken: substring(resourceToken, 0, 6) customDomainName: normalizedCustomDomainName originHostName: containerAppHostName - dnsZoneId: useFrontDoorDns ? resourceId(subscription().subscriptionId, dnsZoneResourceGroup, 'Microsoft.Network/dnsZones', normalizedDnsZoneName) : '' + dnsZoneId: useFrontDoorDns ? resourceId(dnsZoneSubscription, dnsZoneResourceGroup, 'Microsoft.Network/dnsZones', normalizedDnsZoneName) : '' allowedCountryCodes: empty(frontDoorAllowedCountryCodes) ? [] : map(split(frontDoorAllowedCountryCodes, ','), code => toUpper(trim(code))) } } module frontDoorDns 'modules/front-door-dns.bicep' = if (useFrontDoorDns) { name: 'devbrain-front-door-dns-${substring(resourceToken, 0, 6)}' - scope: resourceGroup(dnsZoneResourceGroup) + scope: resourceGroup(dnsZoneSubscription, dnsZoneResourceGroup) params: { dnsZoneName: normalizedDnsZoneName // The custom domain must sit below the zone apex, for example devbrain.contoso.com in contoso.com. diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 3a244e7..1b5a0d5 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -41,12 +41,18 @@ "frontDoorResourceGroupName": { "value": "${FRONT_DOOR_RESOURCE_GROUP=}" }, + "frontDoorSubscriptionId": { + "value": "${FRONT_DOOR_SUBSCRIPTION_ID=}" + }, "dnsZoneName": { "value": "${DNS_ZONE_NAME=}" }, "dnsZoneResourceGroupName": { "value": "${DNS_ZONE_RESOURCE_GROUP=}" }, + "dnsZoneSubscriptionId": { + "value": "${DNS_ZONE_SUBSCRIPTION_ID=}" + }, "frontDoorAllowedCountryCodes": { "value": "${FRONT_DOOR_ALLOWED_COUNTRIES=}" },