From 14378d4ba1882a97a836e5d9edfc7f3b71cfdfd8 Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Sun, 4 Oct 2026 15:16:26 -1000 Subject: [PATCH] Fix first-time provisioning and add serverless Cosmos and Front Door options A fresh `azd provision` could never finish: the Container App timed out with "Operation expired". Two independent bugs caused it, and both are fixed here alongside opt-in options found while deploying a new tenant. - Pull images from ACR with the user-assigned identity. The system-assigned identity only exists once the app does, so its AcrPull grant was created after the app and the first revision waited on it indefinitely. - Replace the containerapps-helloworld placeholder (port 80) with mcr.microsoft.com/dotnet/samples:aspnetapp, which listens on 8080 and serves /healthz, so the first revision passes the template's probes. - Add COSMOS_SERVERLESS for subscriptions whose single free-tier Cosmos account is already taken. Defaults to provisioned throughput. - Add CUSTOM_DOMAIN_NAME, which drives OAuth__BaseUrl, AllowedHosts, and the redirect URI output instead of hand-editing main.bicep. - Add optional publishing through an existing Front Door Standard/Premium profile: dedicated endpoint, route, origin, managed-cert custom domain, Azure DNS records, and a custom-rule WAF policy (path allow list, OAuth and overall per-IP rate limits, optional country allow list). Nothing existing on the profile is modified. - Add an opt-in FrontDoor:Id origin check that rejects requests without the matching X-Azure-FDID header (except /healthz), so the Container Apps host name can't bypass the WAF. - Add DEVBRAIN_PUBLIC_URL and DEVBRAIN_MCP_URL outputs and point the README's verify step at them. - Fix the README's az CLI app-role snippet, which nested the role array and truncated it at the default ConvertTo-Json depth. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 + README.md | 49 +++- infra/main.bicep | 102 +++++++- infra/main.parameters.json | 26 +- infra/modules/front-door-dns.bicep | 42 +++ infra/modules/front-door.bicep | 247 ++++++++++++++++++ src/DevBrain.Server/Program.cs | 26 ++ .../ServerEndpointTests.cs | 46 ++++ 8 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 infra/modules/front-door-dns.bicep create mode 100644 infra/modules/front-door.bicep diff --git a/CHANGELOG.md b/CHANGELOG.md index d34915a..754173d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman ## [Unreleased] +### Added + +- 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 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. + +### Fixed + +- Fixed first-time provisioning of a new environment, which stalled until the Container App operation expired. The Container App pulled from ACR with its system-assigned identity, which receives AcrPull only after the app exists, so the first revision could never start. Image pulls now use the user-assigned identity, which is granted AcrPull before the app is created. +- Replaced the first-provision placeholder image with `mcr.microsoft.com/dotnet/samples:aspnetapp`. The previous placeholder listened on port 80, so it failed the template's port-8080 `/healthz` startup probe and the first revision never became healthy. +- Fixed the README's Azure CLI app-role snippet, which wrapped the role in a nested array and cut it off at the default `ConvertTo-Json` depth, so `az ad app update --app-roles` failed. + ## [2.0.0] — 2026-09-29 DevBrain 2.0 replaces the Azure Functions host with a standalone ASP.NET Core host on Azure Container Apps. The MCP endpoint moves from `/runtime/webhooks/mcp` on the Function App to `/mcp` on the Container App. Existing documents carry over unchanged, and each client signs in once against the new endpoint. See "Upgrading from 1.x" in the README for the cutover and cleanup steps. diff --git a/README.md b/README.md index 4122163..2b79f63 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The MCP transport is stateless, so requests do not require session affinity or a | Public endpoint rate limit | `120` requests per `60` seconds per replica and authenticated object ID (IP fallback) | | Request body limit | `4 MiB` | | CORS | Disabled; configure explicit origins only when a browser client requires them | -| Public edge | Native Container Apps HTTPS FQDN; no Front Door dependency | +| Public edge | Native Container Apps HTTPS FQDN; no Front Door dependency (optionally an existing Front Door profile with a custom domain and WAF) | Minimum replicas are a latency/cost choice. This repository defaults to zero; latency-sensitive interactive deployments should consider one or more warm replicas, consistent with [Microsoft’s stateless MCP hosting guidance](https://techcommunity.microsoft.com/blog/appsonazureblog/mcp-just-went-stateless-%E2%80%94-what-the-2026-spec-changes-about-scaling-on-app-servic/4530222). @@ -108,10 +108,10 @@ $app = az ad app create --display-name DevBrain --sign-in-audience AzureADMyOrg az ad sp create --id $app.appId | Out-Null # creates the enterprise application used for role assignment $clientSecret = az ad app credential reset --id $app.appId --display-name devbrain --years 1 --query password -o tsv -$roles = ConvertTo-Json -AsArray @(@{ +$roles = @(@{ allowedMemberTypes = @('User'); isEnabled = $true; value = 'DevBrain.User' displayName = 'DevBrain User'; description = 'Can connect to DevBrain'; id = [guid]::NewGuid().ToString() -}) +}) | ConvertTo-Json -Depth 5 -AsArray Set-Content -Path approles.json -Value $roles az ad app update --id $app.appId --app-roles approles.json Remove-Item approles.json @@ -155,7 +155,7 @@ azd env set JWT_SIGNING_SECRET "" ### 4. Add the redirect URI ```powershell -azd env get-value OAUTH_REDIRECT_URI # https://ca-devbrain-..azurecontainerapps.io/callback +azd env get-value OAUTH_REDIRECT_URI # https://ca-devbrain-..azurecontainerapps.io/callback, or https:///callback ``` In the portal, open the app registration → **Authentication** → **Add a platform** → **Web**, and paste that value exactly. Or use the CLI: @@ -175,14 +175,44 @@ After the first deployment, `azd up` (provision + deploy) is fine for later upda ### 6. Verify ```powershell -$url = azd env get-value AZURE_CONTAINER_APP_URL +$url = azd env get-value DEVBRAIN_PUBLIC_URL Invoke-RestMethod "$url/healthz" # status: healthy Invoke-RestMethod "$url/.well-known/oauth-authorization-server" # DevBrain's own issuer metadata ``` -Then connect a client (see [Configure Your MCP Client](#configure-your-mcp-client)) at `$url/mcp`. The first tool call opens an Entra sign-in, and every write records the signed-in user's UPN in `updatedBy`. +Then connect a client (see [Configure Your MCP Client](#configure-your-mcp-client)) at `$url/mcp` (also available as `azd env get-value DEVBRAIN_MCP_URL`). The first tool call opens an Entra sign-in, and every write records the signed-in user's UPN in `updatedBy`. -The Container App uses the platform-provided HTTPS host name. Front Door and a custom domain are optional additions, not requirements. If you add a custom domain, update `OAuth__BaseUrl` and `AllowedHosts` in `infra/main.bicep`, and the Entra redirect URI, to match. +By default the Container App uses the platform-provided HTTPS host name, and `DEVBRAIN_PUBLIC_URL` is that host name. A custom domain and Azure Front Door are optional; see the next section. + +### Optional: custom domain and Azure Front Door + +DevBrain can publish through an Azure Front Door Standard or Premium profile you already have. The template adds a dedicated endpoint, route, origin, custom domain with a managed certificate, and a WAF policy to that profile. It doesn't change the profile's existing routes, WAF policies, or security policies. Set these values before `azd provision`, ideally before any client connects, because changing the public URL makes every client sign in again: + +```powershell +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 DNS_ZONE_NAME contoso.com # optional: Azure DNS zone that hosts the domain +azd env set DNS_ZONE_RESOURCE_GROUP +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 +``` + +`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. + +The WAF policy uses custom rules only, so it works on the Standard tier: + +| Rule | Behavior | +|------|----------| +| Country allow list | Blocks clients outside `FRONT_DOOR_ALLOWED_COUNTRIES` when set | +| Path allow list | Blocks anything other than `/mcp`, `/.well-known/*`, `/register`, `/authorize`, `/callback`, `/token`, and `/healthz` | +| OAuth rate limit | `30` requests per minute per client IP to `/register`, `/authorize`, `/callback`, and `/token` | +| Overall rate limit | `1000` requests per five minutes per client IP | + +With Front Door enabled, the app also sets `FrontDoor__Id` to the profile's ID and rejects any request without the matching `X-Azure-FDID` header. As a result, the `*.azurecontainerapps.io` host name can't be used to bypass the WAF. `/healthz` stays open for Container Apps health probes. + +To use a custom domain without Front Door, set only `CUSTOM_DOMAIN_NAME` and [bind the domain to the Container App](https://learn.microsoft.com/azure/container-apps/custom-domains-managed-certificates) yourself. ## First Run @@ -213,13 +243,16 @@ DevBrain 2.0 removes the Azure Functions host. On an existing 1.x environment: - **Client secret expiry.** When the Entra client secret expires, sign-ins and token refreshes fail. Before it expires, create a new secret and write it to the `entra-client-secret` Key Vault secret, either with `az keyvault secret set` or by setting `ENTRA_CLIENT_SECRET` and re-running `azd provision`. Container Apps picks up the new version within 30 minutes. To apply it immediately, restart the active revision with `az containerapp revision restart`. - **JWT signing secret rotation.** Replacing `jwt-signing-secret` invalidates every DevBrain access token already issued, so clients re-authenticate on their next call. It's the "sign everyone out" lever. - **Cold starts.** The template defaults to zero minimum replicas, so the first request after an idle period waits for a container to start. Set `containerAppMinReplicas` to `1` in `infra/main.bicep` if interactive latency matters more than idle cost. -- **Cost profile.** The main fixed costs are Cosmos DB, which uses standard provisioned throughput rather than serverless, and the Basic Azure Container Registry. Container Apps compute scales to zero by default. Check the Cosmos throughput settings against your budget before a long-running trial. +- **Cost profile.** The main fixed costs are Cosmos DB, which uses standard provisioned throughput by default, and the Basic Azure Container Registry. Container Apps compute scales to zero by default. Check the Cosmos throughput settings against your budget before a long-running trial. +- **Serverless Cosmos DB.** If the subscription's one free-tier Cosmos account is already in use, run `azd env set COSMOS_SERVERLESS true` before the first `azd provision`. Cosmos DB then bills per request and per GB stored, which is usually far cheaper for a small team's knowledge store, at the cost of no throughput or latency guarantees. Cosmos DB fixes the capacity mode when it creates the account, so set this before the first provision. - **Tearing down a trial.** The Key Vault has purge protection on, so `azd down` leaves it soft-deleted for 90 days and its name can't be reused in that time. For another trial, create a new azd environment name (`azd env new`) rather than re-provisioning the old one. ## Configure Your MCP Client DevBrain uses OAuth 2.0 with Dynamic Client Registration (DCR). Clients that support the MCP OAuth spec connect with just a URL — no API keys, no manual configuration, no local proxies. The server handles registration, authorization, and token exchange automatically via the built-in DCR facade backed by your Entra tenant. +The examples below use the default Container Apps host name. If you set `CUSTOM_DOMAIN_NAME`, use that host instead; `azd env get-value DEVBRAIN_MCP_URL` prints the exact URL. + ### Claude Code CLI ```bash diff --git a/infra/main.bicep b/infra/main.bicep index b85c567..8992f47 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -31,8 +31,10 @@ param oauthAccessTokenLifetimeMinutes string = '' @description('Optional rotated-refresh-token replay marker lifetime in whole minutes. Leave empty for the application default.') param oauthRefreshReplayLifetimeMinutes string = '' +// The placeholder listens on 8080 and serves /healthz, so the first revision passes the same ingress +// port and probes as DevBrain itself before azd deploys the real image. @description('Container image used when provisioning the Container App. azd replaces it with the built DevBrain image during deployment.') -param containerAppImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' +param containerAppImage string = 'mcr.microsoft.com/dotnet/samples:aspnetapp' @description('Minimum Container App replica count. Set to 1 or higher when interactive cold-start latency is important.') @minValue(0) @@ -42,6 +44,36 @@ param containerAppMinReplicas int = 0 @minValue(1) param containerAppMaxReplicas int = 3 +@description('Create the Cosmos DB account in serverless (pay-per-request) mode instead of provisioned throughput. Useful when the subscription\'s one free-tier account is already taken. Applies only when the account is first created; Cosmos DB cannot switch an existing account to serverless.') +param cosmosServerless bool = false + +// ─── Optional public host name and Azure Front Door ───────────────────────── + +@description('Optional public host name, for example devbrain.contoso.com. When set, OAuth URLs and the Entra redirect URI use it instead of the Container Apps host name.') +param customDomainName string = '' + +@description('Optional existing Azure Front Door Standard/Premium profile to publish DevBrain through. Requires customDomainName. The app then rejects requests that did not come through this profile.') +param frontDoorProfileName string = '' + +@description('Resource group of the Front Door profile. Defaults to this deployment\'s resource group.') +param frontDoorResourceGroupName string = '' + +@description('SKU of the existing Front Door profile; the DevBrain WAF policy is created with the matching SKU.') +@allowed([ + 'Standard_AzureFrontDoor' + 'Premium_AzureFrontDoor' +]) +param frontDoorSkuName string = 'Standard_AzureFrontDoor' + +@description('Optional Azure DNS zone that hosts customDomainName. When set with Front Door, the CNAME and domain-validation TXT records are created there.') +param dnsZoneName string = '' + +@description('Resource group of the Azure DNS zone. Defaults to the Front Door resource group.') +param dnsZoneResourceGroupName 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 = '' + var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) // ─── Storage Account (Data Protection key ring) ───────────────────────────── @@ -87,6 +119,7 @@ resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { properties: { databaseAccountOfferType: 'Standard' enableFreeTier: false + capabilities: cosmosServerless ? [{ name: 'EnableServerless' }] : [] enableAutomaticFailover: true minimalTlsVersion: 'Tls12' defaultIdentity: 'FirstPartyIdentity' @@ -295,6 +328,44 @@ var containerAppName = 'ca-devbrain-${substring(resourceToken, 0, 6)}' var containerAppHostName = '${containerAppName}.${containerAppsEnvironment.properties.defaultDomain}' var containerAppBaseUrl = 'https://${containerAppHostName}' +// Front Door forwards with the Container Apps host name as the Host header, so AllowedHosts keeps it +// alongside the public name. +var publicBaseUrl = empty(customDomainName) ? containerAppBaseUrl : 'https://${customDomainName}' +var allowedHosts = empty(customDomainName) ? containerAppHostName : '${customDomainName};${containerAppHostName}' + +var useFrontDoor = !empty(frontDoorProfileName) && !empty(customDomainName) +var frontDoorResourceGroup = empty(frontDoorResourceGroupName) ? resourceGroup().name : frontDoorResourceGroupName +var useFrontDoorDns = useFrontDoor && !empty(dnsZoneName) +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) + params: { + profileName: frontDoorProfileName + skuName: frontDoorSkuName + resourceToken: substring(resourceToken, 0, 6) + customDomainName: normalizedCustomDomainName + originHostName: containerAppHostName + dnsZoneId: useFrontDoorDns ? resourceId(subscription().subscriptionId, 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) + params: { + dnsZoneName: normalizedDnsZoneName + // The custom domain must sit below the zone apex, for example devbrain.contoso.com in contoso.com. + recordName: useFrontDoorDns ? substring(normalizedCustomDomainName, 0, length(normalizedCustomDomainName) - length(normalizedDnsZoneName) - 1) : '' + endpointHostName: useFrontDoor ? frontDoor!.outputs.endpointHostName : '' + validationToken: useFrontDoor ? frontDoor!.outputs.customDomainValidationToken : '' + } +} + resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { name: containerAppName location: location @@ -314,7 +385,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { registries: [ { server: containerRegistry.properties.loginServer - identity: 'system' + identity: containerAppIdentity.id } ] ingress: { @@ -323,8 +394,9 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { targetPort: 8080 transport: 'auto' } - // The public placeholder image keeps initial provisioning independent of ACR. The - // explicit registry identity lets azd switch to the private built image during deploy. + // The public placeholder image keeps initial provisioning independent of ACR. The registry uses + // the user-assigned identity because its AcrPull grant can exist before the app is created; a + // system-assigned identity only exists once the app does, which stalls the first provision. secrets: [ { name: 'entra-client-secret' @@ -350,7 +422,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { env: [ { name: 'ASPNETCORE_HTTP_PORTS', value: '8080' } { name: 'AZURE_CLIENT_ID', value: containerAppIdentity.properties.clientId } - { name: 'AllowedHosts', value: containerAppHostName } + { name: 'AllowedHosts', value: allowedHosts } { name: 'CosmosDb__AccountEndpoint', value: cosmosAccount.properties.documentEndpoint } { name: 'CosmosDb__DatabaseName', value: 'devbrain' } { name: 'CosmosDb__ContainerName', value: 'documents' } @@ -359,7 +431,8 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { // deployments keep their live sessions; changing it signs every client out. { name: 'CosmosDb__OAuthKeyPrefix', value: 'v2:' } { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: applicationInsights.properties.ConnectionString } - { name: 'OAuth__BaseUrl', value: containerAppBaseUrl } + { name: 'OAuth__BaseUrl', value: publicBaseUrl } + { name: 'FrontDoor__Id', value: useFrontDoor ? frontDoor!.outputs.frontDoorId : '' } { name: 'OAuth__EntraTenantId', value: entraTenantId } { name: 'OAuth__EntraClientId', value: entraClientId } { name: 'OAuth__EntraClientSecret', secretRef: 'entra-client-secret' } @@ -428,6 +501,7 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { } } dependsOn: [ + containerAppIdentityAcrPullRole containerAppStorageBlobDataOwnerRole containerAppKeyVaultCryptoUserRole containerAppKeyVaultSecretsUserRole @@ -454,6 +528,16 @@ resource containerAppAcrPullRole 'Microsoft.Authorization/roleAssignments@2022-0 } } +resource containerAppIdentityAcrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, containerAppIdentity.id, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + scope: containerRegistry + properties: { + principalId: containerAppIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + resource containerAppStorageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(storageAccount.id, containerAppIdentity.id, 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') scope: storageAccount @@ -491,7 +575,11 @@ resource containerAppKeyVaultSecretsUserRole 'Microsoft.Authorization/roleAssign // ─── Outputs ───────────────────────────────────────────────────────────────── output AZURE_CONTAINER_APP_URL string = containerAppBaseUrl -output OAUTH_REDIRECT_URI string = '${containerAppBaseUrl}/callback' +output DEVBRAIN_PUBLIC_URL string = publicBaseUrl +output DEVBRAIN_MCP_URL string = '${publicBaseUrl}/mcp' +output OAUTH_REDIRECT_URI string = '${publicBaseUrl}/callback' +output FRONT_DOOR_ENDPOINT_HOST_NAME string = useFrontDoor ? frontDoor!.outputs.endpointHostName : '' +output FRONT_DOOR_DOMAIN_VALIDATION_TOKEN string = useFrontDoor ? frontDoor!.outputs.customDomainValidationToken : '' output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer output AZURE_CONTAINER_APP_IDENTITY_PRINCIPAL_ID string = containerAppIdentity.properties.principalId diff --git a/infra/main.parameters.json b/infra/main.parameters.json index d225e36..3a244e7 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -26,8 +26,32 @@ "oauthRefreshReplayLifetimeMinutes": { "value": "${OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES=}" }, + "cosmosServerless": { + "value": "${COSMOS_SERVERLESS=false}" + }, + "frontDoorSkuName": { + "value": "${FRONT_DOOR_SKU=Standard_AzureFrontDoor}" + }, + "customDomainName": { + "value": "${CUSTOM_DOMAIN_NAME=}" + }, + "frontDoorProfileName": { + "value": "${FRONT_DOOR_PROFILE_NAME=}" + }, + "frontDoorResourceGroupName": { + "value": "${FRONT_DOOR_RESOURCE_GROUP=}" + }, + "dnsZoneName": { + "value": "${DNS_ZONE_NAME=}" + }, + "dnsZoneResourceGroupName": { + "value": "${DNS_ZONE_RESOURCE_GROUP=}" + }, + "frontDoorAllowedCountryCodes": { + "value": "${FRONT_DOOR_ALLOWED_COUNTRIES=}" + }, "containerAppImage": { - "value": "${SERVICE_SERVER_IMAGE_NAME=mcr.microsoft.com/azuredocs/containerapps-helloworld:latest}" + "value": "${SERVICE_SERVER_IMAGE_NAME=mcr.microsoft.com/dotnet/samples:aspnetapp}" } } } diff --git a/infra/modules/front-door-dns.bicep b/infra/modules/front-door-dns.bicep new file mode 100644 index 0000000..8f7c9f6 --- /dev/null +++ b/infra/modules/front-door-dns.bicep @@ -0,0 +1,42 @@ +// Points a custom domain at its Azure Front Door endpoint in an existing Azure DNS zone and +// publishes the domain-validation TXT record Front Door needs to issue the managed certificate. + +@description('Name of the existing Azure DNS zone, for example contoso.com.') +param dnsZoneName string + +@description('Record name relative to the zone, for example devbrain.') +param recordName string + +@description('Front Door endpoint host name the CNAME points at.') +param endpointHostName string + +@description('Front Door domain-validation token. Empty once Front Door no longer reports one.') +param validationToken string + +resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' existing = { + name: dnsZoneName +} + +resource cname 'Microsoft.Network/dnsZones/CNAME@2018-05-01' = { + parent: dnsZone + name: recordName + properties: { + TTL: 3600 + CNAMERecord: { + cname: endpointHostName + } + } +} + +resource validation 'Microsoft.Network/dnsZones/TXT@2018-05-01' = if (!empty(validationToken)) { + parent: dnsZone + name: '_dnsauth.${recordName}' + properties: { + TTL: 3600 + TXTRecords: [ + { + value: [validationToken] + } + ] + } +} diff --git a/infra/modules/front-door.bicep b/infra/modules/front-door.bicep new file mode 100644 index 0000000..ba9f77f --- /dev/null +++ b/infra/modules/front-door.bicep @@ -0,0 +1,247 @@ +// Publishes DevBrain through an existing Azure Front Door Standard/Premium profile. +// +// Everything here is a new child resource of the profile (endpoint, origin group, origin, custom +// domain, route, security policy) plus a dedicated WAF policy, so other sites on the same profile +// keep their own routes and WAF rules untouched. + +@description('Name of the existing Azure Front Door Standard/Premium profile.') +param profileName string + +@description('SKU of the Front Door profile. The WAF policy SKU must match it.') +@allowed([ + 'Standard_AzureFrontDoor' + 'Premium_AzureFrontDoor' +]) +param skuName string = 'Standard_AzureFrontDoor' + +@description('Suffix that keeps DevBrain resource names unique within the profile.') +param resourceToken string + +@description('Public host name clients use, for example devbrain.contoso.com.') +param customDomainName string + +@description('Container Apps host name that Front Door forwards to.') +param originHostName string + +@description('Resource ID of the Azure DNS zone hosting customDomainName, or empty when DNS is managed elsewhere.') +param dnsZoneId string = '' + +@description('Two-letter country codes allowed through the WAF. Empty allows every country.') +param allowedCountryCodes array = [] + +@description('Requests per minute per client IP allowed to the OAuth endpoints.') +@minValue(10) +param oauthRateLimitPerMinute int = 30 + +@description('Requests per five minutes per client IP allowed across the whole host.') +@minValue(10) +param requestRateLimitPerFiveMinutes int = 1000 + +resource profile 'Microsoft.Cdn/profiles@2024-02-01' existing = { + name: profileName +} + +resource endpoint 'Microsoft.Cdn/profiles/afdEndpoints@2024-02-01' = { + parent: profile + name: 'fde-devbrain-${resourceToken}' + location: 'global' + properties: { + enabledState: 'Enabled' + } +} + +// A single origin gains nothing from Front Door health probes, and the probe traffic would keep a +// scale-to-zero Container App awake, so the origin group leaves probes off. +resource originGroup 'Microsoft.Cdn/profiles/originGroups@2024-02-01' = { + parent: profile + name: 'og-devbrain-${resourceToken}' + properties: { + loadBalancingSettings: { + sampleSize: 4 + successfulSamplesRequired: 3 + additionalLatencyInMilliseconds: 50 + } + sessionAffinityState: 'Disabled' + } +} + +resource origin 'Microsoft.Cdn/profiles/originGroups/origins@2024-02-01' = { + parent: originGroup + name: 'containerapp' + properties: { + hostName: originHostName + originHostHeader: originHostName + httpPort: 80 + httpsPort: 443 + priority: 1 + weight: 1000 + enabledState: 'Enabled' + enforceCertificateNameCheck: true + } +} + +resource customDomain 'Microsoft.Cdn/profiles/customDomains@2024-02-01' = { + parent: profile + name: replace(customDomainName, '.', '-') + properties: { + hostName: customDomainName + tlsSettings: { + certificateType: 'ManagedCertificate' + minimumTlsVersion: 'TLS12' + } + azureDnsZone: empty(dnsZoneId) ? null : { + id: dnsZoneId + } + } +} + +// No cacheConfiguration: every MCP and OAuth response is per-caller, so caching stays off. +resource route 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-02-01' = { + parent: endpoint + name: 'route-devbrain' + properties: { + customDomains: [ + { id: customDomain.id } + ] + originGroup: { + id: originGroup.id + } + supportedProtocols: ['Http', 'Https'] + patternsToMatch: ['/*'] + forwardingProtocol: 'HttpsOnly' + linkToDefaultDomain: 'Disabled' + httpsRedirect: 'Enabled' + enabledState: 'Enabled' + } + dependsOn: [ + origin + ] +} + +// ─── WAF policy ────────────────────────────────────────────────────────────── +// +// Custom rules only, so the policy works on the Standard tier. RequestUri patterns accept an +// optional scheme and host prefix so they match whether Front Door presents the full URL or the path. + +var allowedPathPattern = '^(?:https?://[^/]+)?/(?:(?:mcp|register|authorize|callback|token|healthz)(?:[/?]|$)|\\.well-known/)' +var oauthPathPattern = '^(?:https?://[^/]+)?/(?:register|authorize|callback|token)(?:[/?]|$)' + +var geoRules = empty(allowedCountryCodes) ? [] : [ + { + name: 'BlockOutsideAllowedCountries' + priority: 10 + enabledState: 'Enabled' + ruleType: 'MatchRule' + action: 'Block' + matchConditions: [ + { + matchVariable: 'SocketAddr' + operator: 'GeoMatch' + negateCondition: true + matchValue: allowedCountryCodes + } + ] + } +] + +var baseRules = [ + { + name: 'BlockUnknownPaths' + priority: 20 + enabledState: 'Enabled' + ruleType: 'MatchRule' + action: 'Block' + matchConditions: [ + { + matchVariable: 'RequestUri' + operator: 'RegEx' + negateCondition: true + matchValue: [allowedPathPattern] + transforms: ['Lowercase'] + } + ] + } + { + // /register is open by design (RFC 7591), so cap how fast one client IP can create records. + name: 'RateLimitOAuth' + priority: 30 + enabledState: 'Enabled' + ruleType: 'RateLimitRule' + rateLimitDurationInMinutes: 1 + rateLimitThreshold: oauthRateLimitPerMinute + action: 'Block' + matchConditions: [ + { + matchVariable: 'RequestUri' + operator: 'RegEx' + negateCondition: false + matchValue: [oauthPathPattern] + transforms: ['Lowercase'] + } + ] + } + { + // Every valid request has a Host header, so this matches all traffic. + name: 'RateLimitAll' + priority: 40 + enabledState: 'Enabled' + ruleType: 'RateLimitRule' + rateLimitDurationInMinutes: 5 + rateLimitThreshold: requestRateLimitPerFiveMinutes + action: 'Block' + matchConditions: [ + { + matchVariable: 'RequestHeader' + selector: 'Host' + operator: 'GreaterThan' + negateCondition: false + matchValue: ['0'] + } + ] + } +] + +resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2024-02-01' = { + name: 'wafdevbrain${resourceToken}' + location: 'Global' + sku: { + name: skuName + } + properties: { + policySettings: { + enabledState: 'Enabled' + mode: 'Prevention' + // No rule inspects the body, and skipping inspection avoids size-based rejections of large documents. + requestBodyCheck: 'Disabled' + customBlockResponseStatusCode: 403 + } + customRules: { + rules: concat(geoRules, baseRules) + } + } +} + +resource securityPolicy 'Microsoft.Cdn/profiles/securityPolicies@2024-02-01' = { + parent: profile + name: 'sp-devbrain-${resourceToken}' + properties: { + parameters: { + type: 'WebApplicationFirewall' + wafPolicy: { + id: wafPolicy.id + } + associations: [ + { + domains: [ + { id: customDomain.id } + ] + patternsToMatch: ['/*'] + } + ] + } + } +} + +output frontDoorId string = profile.properties.frontDoorId +output endpointHostName string = endpoint.properties.hostName +output customDomainValidationToken string = customDomain.properties.validationProperties.validationToken diff --git a/src/DevBrain.Server/Program.cs b/src/DevBrain.Server/Program.cs index 7571143..d90a4ae 100644 --- a/src/DevBrain.Server/Program.cs +++ b/src/DevBrain.Server/Program.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using ModelContextProtocol.AspNetCore; @@ -186,9 +187,28 @@ .WithHttpTransport(options => options.Stateless = true) .WithTools(); +var frontDoorId = configuration["FrontDoor:Id"]?.Trim(); + var app = builder.Build(); app.UseExceptionHandler(); +if (!string.IsNullOrEmpty(frontDoorId)) +{ + // Only accept traffic stamped by this deployment's Azure Front Door profile, so the platform host + // name can't be used to bypass the edge WAF. /healthz stays reachable for Container Apps probes, + // which connect to the replica directly. + app.Use(async (context, next) => + { + if (context.Request.Path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) + || IsFromFrontDoor(context.Request.Headers["X-Azure-FDID"], frontDoorId)) + { + await next(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + }); +} if (allowedOrigins.Length > 0) { app.UseCors(); @@ -217,6 +237,12 @@ app.Run(); +// X-Azure-FDID holds a comma-separated list when requests pass through more than one Front Door profile. +static bool IsFromFrontDoor(StringValues headerValues, string frontDoorId) => + headerValues + .SelectMany(value => (value ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .Any(value => string.Equals(value, frontDoorId, StringComparison.OrdinalIgnoreCase)); + static void EnsureConfig(IConfiguration config, string key) { if (string.IsNullOrWhiteSpace(config[key])) diff --git a/tests/DevBrain.Server.Tests/ServerEndpointTests.cs b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs index 75a41c4..37cc921 100644 --- a/tests/DevBrain.Server.Tests/ServerEndpointTests.cs +++ b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs @@ -21,6 +21,8 @@ namespace DevBrain.Server.Tests; public sealed class ServerEndpointTests : IClassFixture { + private const string FrontDoorId = "5f0c2a8e-1d3b-4c6a-9e7f-0a1b2c3d4e5f"; + private readonly HttpClient _client; private readonly DevBrainWebApplicationFactory _factory; @@ -93,6 +95,50 @@ public async Task Healthz_IsAnonymousAndHealthy() Assert.Equal("healthy", body!["status"]); } + [Theory] + [InlineData(null)] + [InlineData("22222222-2222-2222-2222-222222222222")] + public async Task FrontDoorCheck_RejectsRequestsWithoutThisProfilesId(string? frontDoorIdHeader) + { + await using var factory = _factory.WithWebHostBuilder(builder => builder.UseSetting("FrontDoor:Id", FrontDoorId)); + using var client = factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/oauth-protected-resource"); + if (frontDoorIdHeader is not null) + { + request.Headers.Add("X-Azure-FDID", frontDoorIdHeader); + } + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData(FrontDoorId)] + [InlineData("33333333-3333-3333-3333-333333333333, " + FrontDoorId)] + public async Task FrontDoorCheck_AllowsRequestsStampedWithThisProfilesId(string frontDoorIdHeader) + { + await using var factory = _factory.WithWebHostBuilder(builder => builder.UseSetting("FrontDoor:Id", FrontDoorId)); + using var client = factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/oauth-protected-resource"); + request.Headers.Add("X-Azure-FDID", frontDoorIdHeader); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task FrontDoorCheck_LeavesHealthzOpenForPlatformProbes() + { + await using var factory = _factory.WithWebHostBuilder(builder => builder.UseSetting("FrontDoor:Id", FrontDoorId)); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task McpWithoutBearerToken_ReturnsProtectedResourceChallenge() {