├── .devcontainer └── devcontainer.json ├── .github └── dependabot.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── agents └── chat-with-bing.json ├── docs └── media │ ├── azure-machine-learning-authoring.png │ ├── openai-chat-e2e-deployment-amlcompute.png │ ├── openai-chat-e2e-deployment-appservices.png │ ├── openai-end-to-end-baseline-aml-compute.png │ ├── openai-end-to-end-baseline-app-services.png │ ├── openai-end-to-end-baseline-authoring.png │ └── openai-end-to-end.vsdx ├── infra-as-code └── bicep │ ├── ai-agent-blob-storage.bicep │ ├── ai-agent-service-dependencies.bicep │ ├── ai-foundry-project.bicep │ ├── ai-foundry.bicep │ ├── ai-search.bicep │ ├── application-gateway.bicep │ ├── application-insights.bicep │ ├── azure-firewall.bicep │ ├── azure-policies.bicep │ ├── bing-grounding.bicep │ ├── cosmos-db.bicep │ ├── customerUsageAttribution │ └── cuaIdResourceGroup.bicep │ ├── jump-box.bicep │ ├── key-vault.bicep │ ├── main.bicep │ ├── modules │ └── keyvaultRoleAssignment.bicep │ ├── network.bicep │ ├── web-app-storage.bicep │ └── web-app.bicep └── website ├── chatui.zip └── chatui ├── Configuration └── ChatApiOptions.cs ├── Controllers ├── ChatController.cs └── HomeController.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Views └── Home │ └── Index.cshtml ├── appsettings.json ├── chatui.csproj └── wwwroot └── favicon.ico /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AI Agent service chat baseline reference implementation", 3 | "image": "mcr.microsoft.com/devcontainers/dotnet:dev-8.0-jammy", 4 | "runArgs": ["--network=host"], 5 | "remoteUser": "vscode", 6 | "features": { 7 | "ghcr.io/devcontainers/features/azure-cli:1": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "ms-dotnettools.csdevkit", 13 | "ms-azuretools.vscode-bicep" 14 | ], 15 | "settings": {} 16 | } 17 | }, 18 | "forwardPorts": [] 19 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "nuget" # See documentation for possible values 9 | directory: "/website" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.*~ 3 | project.lock.json 4 | .DS_Store 5 | *.pyc 6 | 7 | # Visual Studio Code 8 | .vscode 9 | 10 | .config 11 | 12 | # User-specific files 13 | *.suo 14 | *.user 15 | *.userosscache 16 | *.sln.docstates 17 | 18 | # Build results 19 | [Dd]ebug/ 20 | [Dd]ebugPublic/ 21 | [Rr]elease/ 22 | [Rr]eleases/ 23 | x64/ 24 | x86/ 25 | build/ 26 | bld/ 27 | [Bb]in/ 28 | [Oo]bj/ 29 | [Oo]ut/ 30 | msbuild.log 31 | msbuild.err 32 | msbuild.wrn 33 | 34 | # Visual Studio 2015 35 | .vs/ 36 | 37 | appgw.key 38 | appgw.pfx 39 | appgw.crt 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the AI Agent service chat baseline reference implementation 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: . 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository () for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase main -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Agent service chat baseline reference implementation 2 | 3 | This reference implementation illustrates an approach running a chat application and an AI orchestration layer in a single region. It uses Azure AI Agent service as the orchestrator and OpenAI foundation models. This repository directly supports the [Baseline end-to-end chat reference architecture](https://learn.microsoft.com/azure/architecture/ai-ml/architecture/baseline-openai-e2e-chat) on Microsoft Learn. 4 | 5 | Follow this implementation to deploy an agent in [Azure AI Foundry](https://learn.microsoft.com/azure/ai-foundry/) and uses Bing for grounding data. You'll be exposed to common generative AI chat application characteristics such as: 6 | 7 | - Creating agents and agent prompts 8 | - Querying data stores for grounding data 9 | - Chat memory database 10 | - Orchestration logic 11 | - Calling language models (such as GPT models) from your agent 12 | 13 | This implementation builds off the [basic implementation](https://github.com/Azure-Samples/openai-end-to-end-basic), and adds common production requirements such as: 14 | 15 | - Network isolation 16 | - Bring-your-own Azure AI Agent service dependencies (for security and BC/DR control) 17 | - Added availability zone reliability 18 | - Limit egress network traffic with Azure Firewall 19 | 20 | ## Architecture 21 | 22 | The implementation covers the following scenarios: 23 | 24 | - [Setting up Azure AI Foundry to host agents](#setting-up-azure-ai-foundry-to-host-agents) 25 | - [Deploying an agent into Azure AI Agent service](#deploying-an-agent-into-azure-ai-agent-service) 26 | - [Invoking the agent from .NET code hosted in an Azure Web App](#invoking-the-agent-from-net-code-hosted-in-an-azure-web-app) 27 | 28 | ### Setting up Azure AI Foundry to host agents 29 | 30 | Azure AI Foundry hosts Azure AI Agent service as a capability. Azure AI Agent service's REST APIs are exposed as a AI Foundry private endpoint within the network, and the agents' all egress through a delegated subnet which is routed through Azure Firewall for any internet traffic. This architecture deploys the Azure AI Agent service with its depedencies hosted within your own Azure subscription. As such, this architecture includes an Azure Storage account, Azure AI Search instance, and an Azure Cosmos DB account specifically for the Azure AI Agent service to manage. 31 | 32 | ### Deploying an agent into Azure AI Agent service 33 | 34 | Agents can be created via the Azure AI Foundry portal, [Azure AI Agents SDK](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/ai/Azure.AI.Agents.Persistent), or the [REST API](https://learn.microsoft.com/rest/api/aifoundry/aiagents/). The creation and invocation of agents are a data plane operation. Since the data plane to Azure AI Foundry is private, all three of those are restricted to being executed from within a private network connected to the private endpoint of Azure AI Foundry. 35 | 36 | Ideally agents should be source-controlled and a versioned asset. You then can deploy agents in a coordinated way with the rest of your workload's code. In this deployment guide, you'll create an agent from the jump box to simulate a deployment pipeline which could have created the agent. 37 | 38 | 39 | If using the Azure AI Foundry portal is desired, then the web browser experience must be performed from a VM within the network or from a workstation that has VPN access to the private network and can properly resolve private DNS records. 40 | 41 | ### Invoking the agent from .NET code hosted in an Azure Web App 42 | 43 | A chat UI application is deployed into a private Azure App Service. The UI is accessed through Application Gateway (WAF). The .NET code uses the [Azure AI Agents SDK](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/ai/Azure.AI.Agents.Persistent) to connect to the workload's agent. The endpoint for the agent is exposed exclusively through the Azure AI Foundry private endpoint. 44 | 45 | ## Deployment guide 46 | 47 | Follow these instructions to deploy this example to your Azure subscription, try out what you've deployed, and learn how to clean up those resources. 48 | 49 | ### Prerequisites 50 | 51 | - An [Azure subscription](https://azure.microsoft.com/free/) 52 | 53 | - The subscription must have all of the resource providers used in this deployment [registered](https://learn.microsoft.com/azure/azure-resource-manager/management/resource-providers-and-types#register-resource-provider). 54 | 55 | - `Microsoft.AlertsManagement` 56 | - `Microsoft.App` 57 | - `Microsoft.Bing` 58 | - `Microsoft.CognitiveServices` 59 | - `Microsoft.Compute` 60 | - `Microsoft.DocumentDB` 61 | - `Microsoft.Insights` 62 | - `Microsoft.KeyVault` 63 | - `Microsoft.ManagedIdentity` 64 | - `Microsoft.Network` 65 | - `Microsoft.OperationalInsights` 66 | - `Microsoft.Search` 67 | - `Microsoft.Storage` 68 | - `Microsoft.Web` 69 | 70 | - The subscription must have the following quota available in the region you choose. 71 | 72 | - Application Gateways: 1 WAF_v2 tier instance 73 | - App Service Plans: P1v3 (AZ), 3 instances 74 | - Azure AI Search (S - Standard): 1 75 | - Azure Cosmos DB: 1 account 76 | - OpenAI model: GPT-4o model deployment with 50k tokens per minute (TPM) capacity 77 | - DDoS Protection Plans: 1 78 | - Public IPv4 Addresses - Standard: 4 79 | - Standard DSv3 Family vCPU: 2 80 | - Storage Accounts: 2 81 | 82 | - Your deployment user must have the following permissions at the subscription scope. 83 | 84 | - Ability to assign [Azure roles](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles) on newly created resource groups and resources. (E.g. `User Access Administrator` or `Owner`) 85 | - Ability to purge deleted AI services resources. (E.g. `Contributor` or `Cognitive Services Contributor`) 86 | 87 | - The [Azure CLI installed](https://learn.microsoft.com/cli/azure/install-azure-cli) 88 | 89 | > :bulb: If you're executing this from Windows Subsystem for Linux (WSL), be sure the Azure CLI is installed in WSL and is not using the version installed in Windows. `which az` must show `/usr/bin/az`. 90 | 91 | - The [OpenSSL CLI](https://docs.openssl.org/3.5/man7/ossl-guide-introduction/#getting-and-installing-openssl) installed. 92 | 93 | ### 1. :rocket: Deploy the infrastructure 94 | 95 | The following steps are required to deploy the infrastructure from the command line using the bicep files from this repository. 96 | 97 | 1. In your shell, clone this repo and navigate to the root directory of this repository. 98 | 99 | ```bash 100 | git clone https://github.com/Azure-Samples/openai-end-to-end-baseline 101 | cd openai-end-to-end-baseline 102 | ``` 103 | 104 | 1. Log in and select your target subscription. 105 | 106 | ```bash 107 | az login 108 | az account set --subscription xxxxx 109 | ``` 110 | 111 | 1. Obtain the App gateway certificate 112 | 113 | Azure Application Gateway includes support for secure TLS using Azure Key Vault and managed identities for Azure resources. This configuration enables end-to-end encryption of the network traffic going to the web application. 114 | 115 | - Set a variable for the domain used in the rest of this deployment. 116 | 117 | ```bash 118 | DOMAIN_NAME_APPSERV="contoso.com" 119 | ``` 120 | 121 | - Generate a client-facing, self-signed TLS certificate. 122 | 123 | > :warning: Do not use the certificate created by this script for production deployments. The use of self-signed certificates are provided for ease of illustration purposes only. For your chat application traffic, use your organization's requirements for procurement and lifetime management of TLS certificates, *even for development purposes*. 124 | 125 | Create the certificate that will be presented to web clients by Azure Application Gateway for your domain. 126 | 127 | ```bash 128 | openssl req -x509 -nodes -days 365 -newkey rsa:2048 -out appgw.crt -keyout appgw.key -subj "/CN=${DOMAIN_NAME_APPSERV}/O=Contoso" -addext "subjectAltName = DNS:${DOMAIN_NAME_APPSERV}" -addext "keyUsage = digitalSignature" -addext "extendedKeyUsage = serverAuth" 129 | openssl pkcs12 -export -out appgw.pfx -in appgw.crt -inkey appgw.key -passout pass: 130 | ``` 131 | 132 | - Base64 encode the client-facing certificate. 133 | 134 | :bulb: No matter if you used a certificate from your organization or generated one from above, you'll need the certificate (as `.pfx`) to be Base64 encoded for storage in Key Vault. 135 | 136 | ```bash 137 | APP_GATEWAY_LISTENER_CERTIFICATE_APPSERV=$(cat appgw.pfx | base64 | tr -d '\n') 138 | echo APP_GATEWAY_LISTENER_CERTIFICATE_APPSERV: $APP_GATEWAY_LISTENER_CERTIFICATE_APPSERV 139 | ``` 140 | 141 | 1. Set the deployment location to one that [supports availability zones](https://learn.microsoft.com/azure/reliability/availability-zones-service-support) and has available quota. 142 | 143 | This deployment has been tested in the following locations: `eastus`, `eastus2`, `switzerlandnorth`. You might be successful in other locations as well. 144 | 145 | ```bash 146 | LOCATION=eastus2 147 | ``` 148 | 149 | 1. Set the base name value that will be used as part of the Azure resource names for the resources deployed in this solution. 150 | 151 | ```bash 152 | BASE_NAME= 153 | ``` 154 | 155 | 1. Create a resource group and deploy the infrastructure. 156 | 157 | *There is an optional tracking ID on this deployment. To opt out of its use, add the following parameter to the deployment code below: `-p telemetryOptOut true`.* 158 | 159 | You will be prompted for an admin password for the jump box; it must satisfy the [complexity requirements for Windows](https://learn.microsoft.com/windows/security/threat-protection/security-policy-settings/password-must-meet-complexity-requirements). 160 | 161 | :clock8: *This might take about 35 minutes.* 162 | 163 | ```bash 164 | RESOURCE_GROUP=rg-chat-baseline-${BASE_NAME} 165 | az group create -l $LOCATION -n $RESOURCE_GROUP 166 | 167 | PRINCIPAL_ID=$(az ad signed-in-user show --query id -o tsv) 168 | 169 | az deployment group create -f ./infra-as-code/bicep/main.bicep \ 170 | -g $RESOURCE_GROUP \ 171 | -p appGatewayListenerCertificate=${APP_GATEWAY_LISTENER_CERTIFICATE_APPSERV} \ 172 | -p baseName=${BASE_NAME} \ 173 | -p yourPrincipalId=${PRINCIPAL_ID} 174 | ``` 175 | 176 | ### 2. Deploy an agent in the Azure AI Agent service 177 | 178 | To test this scenario, you'll be deploying an AI agent included in this repository. The agent uses a GPT model combined with a Bing search for grounding data. Deploying an AI agent requires data plane access to Azure AI Foundry. In this architecture, a network perimeter is established, and you must interact with the Azure AI Foundry portal and its resources from within the network. 179 | 180 | The AI agent definition would likely be deployed from your application's pipeline running from a build agent in your workload's network or it could be deployed via singleton code in your web application. In this deployment, you'll create the agent from the jump box, which most closely simulates pipeline-based creation. 181 | 182 | 1. Connect to the virtual network via the deployed [Azure Bastion and the jump box](https://learn.microsoft.com/azure/bastion/bastion-connect-vm-rdp-windows#rdp). Alternatively, you can connect through a force-tunneled VPN or virtual network peering that you manually configure apart from these instructions. 183 | 184 | The username for the Windows jump box deployed in this solution is `vmadmin`. You provided the password during the deployment. 185 | 186 | | :computer: | Unless otherwise noted, the following steps are performed from the jump box or from your VPN-connected workstation. The instructions are written as if you are using the provided Windows jump box.| 187 | | :--------: | :------------------------- | 188 | 189 | 1. Open PowerShell from the Terminal app. Log in and select your target subscription. 190 | 191 | ```powershell 192 | az login 193 | az account set --subscription xxxxx 194 | ``` 195 | 196 | 1. Set the base name to the same value it was when you deployed the resources. 197 | 198 | ```powershell 199 | $BASE_NAME="" 200 | ``` 201 | 202 | 1. Generate some variables to set context within your jump box. 203 | 204 | *The following variables align with the defaults in this deployment. Update them if you customized anything.* 205 | 206 | ```powershell 207 | $RESOURCE_GROUP="rg-chat-baseline-${BASE_NAME}" 208 | $AI_FOUNDRY_NAME="aif${BASE_NAME}" 209 | $BING_CONNECTION_NAME="bingaiagent" 210 | $AI_FOUNDRY_PROJECT_NAME="projchat" 211 | $MODEL_CONNECTION_NAME="agent-model" 212 | $BING_CONNECTION_ID="$(az cognitiveservices account show -n $AI_FOUNDRY_NAME -g $RESOURCE_GROUP --query 'id' --out tsv)/projects/${$AI_FOUNDRY_PROJECT_NAME}$/connections/${BING_CONNECTION_NAME}" 213 | $AI_FOUNDRY_AGENT_CREATE_URL="https://${AI_FOUNDRY_NAME}.services.ai.azure.com/api/projects/${AI_FOUNDRY_PROJECT_NAME}/assistants?api-version=2025-05-15-preview" 214 | 215 | echo $BING_CONNECTION_ID 216 | echo $MODEL_CONNECTION_NAME 217 | echo $AI_FOUNDRY_AGENT_CREATE_URL 218 | ``` 219 | 220 | 1. Deploy the agent. 221 | 222 | *This step simulates deploying an AI agent through your pipeline from a network-connected build agent.* 223 | 224 | ```powershell 225 | # Use the agent definition on disk 226 | Invoke-WebRequest -Uri "https://github.com/Azure-Samples/openai-end-to-end-baseline/raw/refs/heads/main/agents/chat-with-bing.json" -OutFile "chat-with-bing.json" 227 | 228 | # Update to match your environment 229 | ${c:chat-with-bing-output.json} = ${c:chat-with-bing.json} -replace 'MODEL_CONNECTION_NAME', $MODEL_CONNECTION_NAME -replace 'BING_CONNECTION_ID', $BING_CONNECTION_ID 230 | 231 | # Deploy the agent 232 | az rest -u $AI_FOUNDRY_AGENT_CREATE_URL -m "post" --resource "https://ai.azure.com" -b @chat-with-bing-output.json 233 | 234 | # Capture the Agent's ID 235 | $AGENT_ID="$(az rest -u $AI_FOUNDRY_AGENT_CREATE_URL -m 'get' --resource 'https://ai.azure.com' --query 'data[0].id' -o tsv)" 236 | 237 | echo $AGENT_ID 238 | ``` 239 | 240 | ### 3. Test the agent from the Azure AI Foundry portal in the playground. *Optional.* 241 | 242 | Here you'll test your orchestration agent by invoking it directly from the Azure AI Foundry portal's playground experience. The Azure AI Foundry portal is only accessible from your private network, so you'll do this from your jump box. 243 | 244 | *This step testing step is completely optional.* 245 | 246 | 1. Open the Azure portal to your subscription. 247 | 248 | You'll need to sign in to the Azure portal, and resolve any Entra ID Conditional Access policies on your account, if this is the first time you are connecting through the jump box. 249 | 250 | 1. Navigate to the Azure AI Foundry project named **projchat** in your resource group and open the Azure AI Foundry portal by clicking the **Go to Azure AI Foundry portal** button. 251 | 252 | This will take you directly into the 'Chat project'. Alternatively, you can find all your AI Foundry accounts and projects by going to and you do not need to use the Azure portal to access them. 253 | 254 | 1. Click **Agents** in the side navigation. 255 | 256 | 1. Select the agent named 'Baseline Chatbot Agent'. 257 | 258 | 1. Click the **Try in playground** button. 259 | 260 | 1. Enter a question that would require grounding data through recent internet content, such as a notable recent event or the weather today in your location. 261 | 262 | 1. A grounded response to your question should appear on the UI. 263 | 264 | ### 4. Publish the chat front-end web app 265 | 266 | Workloads build chat functionality into an application. Those interfaces usually call APIs which in turn call into your orchestrator. This implementation comes with such an interface. You'll deploy it to Azure App Service using its [run from package](https://learn.microsoft.com/azure/app-service/deploy-run-package) capabilities. 267 | 268 | In a production environment, you use a CI/CD pipeline to: 269 | 270 | - Build your web application 271 | - Create the project zip package 272 | - Upload the zip file to your Storage account from compute that is in or connected to the workload's virtual network. 273 | 274 | For this deployment guide, you'll continue using your jump box to simulate part of that process. 275 | 276 | 1. Using the same PowerShell terminal session from previous steps, download the web UI. 277 | 278 | ```powershell 279 | Invoke-WebRequest -Uri https://github.com/Azure-Samples/openai-end-to-end-baseline/raw/refs/heads/main/website/chatui.zip -OutFile chatui.zip 280 | ``` 281 | 282 | 1. Upload the web application to Azure Storage, where the web app will load the code from. 283 | 284 | ```powershell 285 | az storage blob upload -f chatui.zip --account-name "stwebapp${BASE_NAME}" --auth-mode login -c deploy -n chatui.zip 286 | ``` 287 | 288 | 1. Update the app configuration to use the agent you deployed. 289 | 290 | ```powershell 291 | az webapp config appsettings set -n "app-${BASE_NAME}" -g $RESOURCE_GROUP --settings AIAgentId="${AGENT_ID}" 292 | ``` 293 | 294 | 1. Restart the web app to load the site code and its updated configuation. 295 | 296 | ```powershell 297 | az webapp restart --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP 298 | ``` 299 | 300 | ### 5. Try it out! Test the deployed application that calls into the Azure AI Agent service 301 | 302 | This section will help you to validate that the workload is exposed correctly and responding to HTTP requests. This will validate that traffic is flowing through Application Gateway, into your Web App, and from your Web App, into the Azure AI Foundry agent API endpoint, which hosts the agent and its chat history. The agent will interface with Bing for grounding data and an OpenAI model for generative responses. 303 | 304 | | :computer: | Unless otherwise noted, the following steps are all performed from your original workstation, not from the jump box. | 305 | | :--------: | :------------------------- | 306 | 307 | 1. Get the public IP address of the Application Gateway. 308 | 309 | ```bash 310 | # Query the Azure Application Gateway Public IP 311 | APPGW_PUBLIC_IP=$(az network public-ip show -g $RESOURCE_GROUP -n "pip-$BASE_NAME" --query [ipAddress] --output tsv) 312 | echo APPGW_PUBLIC_IP: $APPGW_PUBLIC_IP 313 | ``` 314 | 315 | 1. Create an `A` record for DNS. 316 | 317 | > :bulb: You can simulate this via a local hosts file modification. Alternatively, you can add a real DNS entry for your specific deployment's application domain name if permission to do so. 318 | 319 | Map the Azure Application Gateway public IP address to the application domain name. To do that, please edit your hosts file (`C:\Windows\System32\drivers\etc\hosts` or `/etc/hosts`) and add the following record to the end: `${APPGW_PUBLIC_IP} www.${DOMAIN_NAME_APPSERV}` (e.g. `50.140.130.120 www.contoso.com`) 320 | 321 | 1. Browse to the site (e.g. ). 322 | 323 | > :bulb: It may take up to a few minutes for the App Service to start properly. Remember to include the protocol prefix `https://` in the URL you type in your browser's address bar. A TLS warning will be present due to using a self-signed certificate. You can ignore it or import the self-signed cert (`appgw.pfx`) to your user's trusted root store. 324 | 325 | Once you're there, ask your solution a question. Your question should involve something that would only be known if the RAG process included context from Bing such as recent weather or events. 326 | 327 | ## :broom: Clean up resources 328 | 329 | Most Azure resources deployed in the prior steps will incur ongoing charges unless removed. This deployment is typically over $90 a day, and more if you enabled Azure DDoS Protection. Promptly delete resources when you are done using them. 330 | 331 | Additionally, a few of the resources deployed enter soft delete status which will restrict the ability to redeploy another resource with the same name or DNS entry; and might not release quota. It's best to purge any soft deleted resources once you are done exploring. Use the following commands to delete the deployed resources and resource group and to purge each of the resources with soft delete. 332 | 333 | 1. Delete the resource group as a way to delete all contained Azure resources. 334 | 335 | | :warning: | This will completely delete any data you may have included in this example. That data and this deployment will be unrecoverable. | 336 | | :-------: | :------------------------- | 337 | 338 | :clock8: *This might take about 20 minutes.* 339 | 340 | ```bash 341 | # This command will delete most of the resources, but will sometimes error out. That's expected. 342 | az group delete -n $RESOURCE_GROUP -y 343 | 344 | # Continue, even if the previous command errored. 345 | ``` 346 | 347 | 1. Purge soft-deleted resources. 348 | 349 | ```bash 350 | # Purge the soft delete resources. 351 | az keyvault purge -n kv-${BASE_NAME} -l $LOCATION 352 | az cognitiveservices account purge -g $RESOURCE_GROUP -l $LOCATION -n aif${BASE_NAME} 353 | ``` 354 | 355 | 1. [Remove the Azure Policy assignments](https://portal.azure.com/#blade/Microsoft_Azure_Policy/PolicyMenuBlade/Compliance) scoped to the resource group. To identify those created by this implementation, look for ones that are prefixed with `[BASE_NAME] `. 356 | 357 | > [!TIP] 358 | > The `vnet-workload` and associated networking resources are sometimes blocked from being deleted with the above instructions. This is because the Azure AI Agent subnet (`snet-agentsEgress`) retains a latent Microsoft-managed deletgated connection (`serviceAssociationLink`) to the deleted AI Agent service backend. The virtual network and associated resources typically become free to delete about an hour after purging the Azure AI Foundry account. 359 | > 360 | > The lingering resources do not have a cost associated with them existing in your subscription. 361 | > 362 | > If the resource group didn't fully delete, re-execute the `az group delete -n $RESOURCE_GROUP -y` command after an hour to complete the cleanup. 363 | 364 | ## Contributions 365 | 366 | Please see our [Contributor guide](./CONTRIBUTING.md). 367 | 368 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact with any additional questions or comments. 369 | 370 | With :heart: from Azure Patterns & Practices, [Azure Architecture Center](https://azure.com/architecture). 371 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /agents/chat-with-bing.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Baseline Chatbot Agent", 3 | "description": "Example Azure AI Agent agent that uses the Bing Search tool to answer questions. Used in the Microsoft Learn AI chat reference architecture. https://learn.microsoft.com/azure/architecture/ai-ml/architecture/baseline-openai-e2e-chat", 4 | "model": "MODEL_CONNECTION_NAME", 5 | "instructions": "You are a helpful Chatbot agent. You'll consult the Bing Search tool to answer questions. Always search the web for information before responding.", 6 | "tools": [ 7 | { 8 | "type": "bing_grounding", 9 | "bing_grounding": { 10 | "search_configurations": [ 11 | { 12 | "connection_id": "BING_CONNECTION_ID", 13 | "count": 5, 14 | "freshness": "Week" 15 | } 16 | ] 17 | } 18 | } 19 | ], 20 | "tool_resources": {}, 21 | "temperature": 1, 22 | "top_p": 1, 23 | "metadata": { 24 | "createdBy": "Microsoft Learn Baseline Architecture", 25 | "version": "1.0.0" 26 | }, 27 | "response_format": "auto" 28 | } -------------------------------------------------------------------------------- /docs/media/azure-machine-learning-authoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/azure-machine-learning-authoring.png -------------------------------------------------------------------------------- /docs/media/openai-chat-e2e-deployment-amlcompute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-chat-e2e-deployment-amlcompute.png -------------------------------------------------------------------------------- /docs/media/openai-chat-e2e-deployment-appservices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-chat-e2e-deployment-appservices.png -------------------------------------------------------------------------------- /docs/media/openai-end-to-end-baseline-aml-compute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-end-to-end-baseline-aml-compute.png -------------------------------------------------------------------------------- /docs/media/openai-end-to-end-baseline-app-services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-end-to-end-baseline-app-services.png -------------------------------------------------------------------------------- /docs/media/openai-end-to-end-baseline-authoring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-end-to-end-baseline-authoring.png -------------------------------------------------------------------------------- /docs/media/openai-end-to-end.vsdx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/docs/media/openai-end-to-end.vsdx -------------------------------------------------------------------------------- /infra-as-code/bicep/ai-agent-blob-storage.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('Assign your user some roles to support access to the Azure AI Agent dependencies for troubleshooting post deployment') 13 | @maxLength(36) 14 | @minLength(36) 15 | param debugUserPrincipalId string 16 | 17 | @description('The name of the workload\'s existing Log Analytics workspace.') 18 | @minLength(4) 19 | param logAnalyticsWorkspaceName string 20 | 21 | @description('The resource ID for the subnet that private endpoints in the workload should surface in.') 22 | @minLength(1) 23 | param privateEndpointSubnetResourceId string 24 | 25 | // ---- Existing resources ---- 26 | 27 | resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 28 | name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' 29 | scope: subscription() 30 | } 31 | 32 | resource blobStorageLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 33 | name: 'privatelink.blob.${environment().suffixes.storage}' 34 | } 35 | 36 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 37 | name: logAnalyticsWorkspaceName 38 | } 39 | 40 | // ---- New resources ---- 41 | 42 | resource agentStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { 43 | name: 'stagent${baseName}' 44 | location: location 45 | sku: { 46 | name: 'Standard_GZRS' 47 | } 48 | kind: 'StorageV2' 49 | properties: { 50 | allowedCopyScope: 'AAD' 51 | accessTier: 'Hot' 52 | allowBlobPublicAccess: false 53 | allowSharedKeyAccess: false 54 | isLocalUserEnabled: false 55 | defaultToOAuthAuthentication: true 56 | allowCrossTenantReplication: false 57 | publicNetworkAccess: 'Disabled' 58 | minimumTlsVersion: 'TLS1_2' 59 | supportsHttpsTrafficOnly: true 60 | isHnsEnabled: false 61 | isSftpEnabled: false 62 | isNfsV3Enabled: false 63 | encryption: { 64 | keySource: 'Microsoft.Storage' 65 | requireInfrastructureEncryption: false // This Azure AI Agents service binary files scenario doesn't require double encryption, but if your scenario does, please enable. 66 | services: { 67 | blob: { 68 | enabled: true 69 | keyType: 'Account' 70 | } 71 | } 72 | } 73 | networkAcls: { 74 | bypass: 'AzureServices' 75 | defaultAction: 'Deny' 76 | virtualNetworkRules: [] 77 | ipRules: [] 78 | resourceAccessRules: [] 79 | } 80 | } 81 | 82 | resource blob 'blobServices' existing = { 83 | name: 'default' 84 | } 85 | } 86 | 87 | // Role assignments 88 | 89 | resource debugUserBlobDataOwnerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 90 | name: guid(debugUserPrincipalId, storageBlobDataOwnerRole.id, agentStorageAccount.id) 91 | scope: agentStorageAccount 92 | properties: { 93 | principalId: debugUserPrincipalId 94 | roleDefinitionId: storageBlobDataOwnerRole.id 95 | principalType: 'User' 96 | } 97 | } 98 | 99 | 100 | // Private endpoints 101 | 102 | resource storagePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 103 | name: 'pe-ai-agent-storage' 104 | location: location 105 | properties: { 106 | subnet: { 107 | id: privateEndpointSubnetResourceId 108 | } 109 | customNetworkInterfaceName: 'nic-ai-agent-storage' 110 | privateLinkServiceConnections: [ 111 | { 112 | name: 'ai-agent-storage' 113 | properties: { 114 | privateLinkServiceId: agentStorageAccount.id 115 | groupIds: [ 116 | 'blob' 117 | ] 118 | } 119 | } 120 | ] 121 | } 122 | 123 | resource dnsGroup 'privateDnsZoneGroups' = { 124 | name: 'ai-agent-storage' 125 | properties: { 126 | privateDnsZoneConfigs: [ 127 | { 128 | name: 'ai-agent-storage' 129 | properties: { 130 | privateDnsZoneId: blobStorageLinkedPrivateDnsZone.id 131 | } 132 | } 133 | ] 134 | } 135 | } 136 | } 137 | 138 | // Azure diagnostics 139 | 140 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 141 | name: 'default' 142 | scope: agentStorageAccount::blob 143 | properties: { 144 | workspaceId: logAnalyticsWorkspace.id 145 | logs: [ 146 | { 147 | category: 'StorageRead' 148 | enabled: true 149 | retentionPolicy: { 150 | enabled: false 151 | days: 0 152 | } 153 | } 154 | { 155 | category: 'StorageWrite' 156 | enabled: true 157 | retentionPolicy: { 158 | enabled: false 159 | days: 0 160 | } 161 | } 162 | { 163 | category: 'StorageDelete' 164 | enabled: true 165 | retentionPolicy: { 166 | enabled: false 167 | days: 0 168 | } 169 | } 170 | ] 171 | } 172 | } 173 | 174 | // ---- Outputs ---- 175 | 176 | output storageAccountName string = agentStorageAccount.name 177 | -------------------------------------------------------------------------------- /infra-as-code/bicep/ai-agent-service-dependencies.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('Assign your user some roles to support access to the Azure AI Agent dependencies for troubleshooting post deployment') 13 | @maxLength(36) 14 | @minLength(36) 15 | param debugUserPrincipalId string 16 | 17 | @description('The name of the workload\'s existing Log Analytics workspace.') 18 | @minLength(4) 19 | param logAnalyticsWorkspaceName string 20 | 21 | @description('The resource ID for the subnet that private endpoints in the workload should surface in.') 22 | @minLength(1) 23 | param privateEndpointSubnetResourceId string 24 | 25 | // ---- New resources ---- 26 | 27 | @description('Deploy Azure Storage account for the Azure AI Agent service (dependency). This is used for binaries uploaded within threads or as "knowledge" uploaded as part of an agent.') 28 | module deployAgentStorageAccount 'ai-agent-blob-storage.bicep' = { 29 | scope: resourceGroup() 30 | params: { 31 | location: location 32 | baseName: baseName 33 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 34 | debugUserPrincipalId: debugUserPrincipalId 35 | privateEndpointSubnetResourceId: privateEndpointSubnetResourceId 36 | } 37 | } 38 | 39 | @description('Deploy Azure Cosmos DB account for the Azure AI Agent service (dependency). This is used for storing agent definitions and threads.') 40 | module deployCosmosDbThreadStorageAccount 'cosmos-db.bicep' = { 41 | scope: resourceGroup() 42 | params: { 43 | location: location 44 | baseName: baseName 45 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 46 | debugUserPrincipalId: debugUserPrincipalId 47 | privateEndpointSubnetResourceId: privateEndpointSubnetResourceId 48 | } 49 | } 50 | 51 | @description('Deploy Azure AI Search instance for the Azure AI Agent service (dependency). This is used when a user uploads a file to the agent, and the agent needs to search for information in that file.') 52 | module deployAzureAISearchService 'ai-search.bicep' = { 53 | scope: resourceGroup() 54 | params: { 55 | location: location 56 | baseName: baseName 57 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 58 | debugUserPrincipalId: debugUserPrincipalId 59 | privateEndpointSubnetResourceId: privateEndpointSubnetResourceId 60 | } 61 | } 62 | 63 | // ---- Outputs ---- 64 | 65 | output cosmosDbAccountName string = deployCosmosDbThreadStorageAccount.outputs.cosmosDbAccountName 66 | output storageAccountName string = deployAgentStorageAccount.outputs.storageAccountName 67 | output aiSearchName string = deployAzureAISearchService.outputs.aiSearchName 68 | -------------------------------------------------------------------------------- /infra-as-code/bicep/ai-foundry-project.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('The existing Azure AI Foundry account. This project will become a child resource of this account.') 8 | @minLength(2) 9 | param existingAiFoundryName string 10 | 11 | @description('The existing Azure Cosmos DB account that is going to be used as the Azure AI Agent thread storage database (dependency).') 12 | @minLength(3) 13 | param existingCosmosDbAccountName string 14 | 15 | @description('The existing Azure Storage account that is going to be used as the Azure AI Agent blob store (dependency).') 16 | @minLength(3) 17 | param existingStorageAccountName string 18 | 19 | @description('The existing Azure AI Search account that is going to be used as the Azure AI Agent vector store (dependency).') 20 | @minLength(1) 21 | param existingAISearchAccountName string 22 | 23 | @description('The existing Bing grounding data account that is available to Azure AI Agent agents in this project.') 24 | @minLength(1) 25 | param existingBingAccountName string 26 | 27 | @description('The existing Application Insights instance to log token usage in this project.') 28 | @minLength(1) 29 | param existingWebApplicationInsightsResourceName string 30 | 31 | // ---- Existing resources ---- 32 | 33 | @description('The internal ID of the project is used in the Azure Storage blob containers and in the Cosmos DB collections.') 34 | #disable-next-line BCP053 35 | var workspaceId = aiFoundry::project.properties.internalId 36 | var workspaceIdAsGuid = '${substring(workspaceId, 0, 8)}-${substring(workspaceId, 8, 4)}-${substring(workspaceId, 12, 4)}-${substring(workspaceId, 16, 4)}-${substring(workspaceId, 20, 12)}' 37 | 38 | var scopeUserContainerId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDbAccount.name}/dbs/enterprise_memory/colls/${workspaceIdAsGuid}-thread-message-store' 39 | var scopeSystemContainerId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDbAccount.name}/dbs/enterprise_memory/colls/${workspaceIdAsGuid}-system-thread-message-store' 40 | var scopeEntityContainerId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDbAccount.name}/dbs/enterprise_memory/colls/${workspaceIdAsGuid}-agent-entity-store' 41 | 42 | @description('Existing Azure Cosmos DB account. Will be assigning Data Contributor role to the Azure AI Foundry project\'s identity.') 43 | resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { 44 | name: existingCosmosDbAccountName 45 | 46 | @description('Built-in Cosmos DB Data Contributor role that can be assigned to Entra identities to grant data access on a Cosmos DB database.') 47 | resource dataContributorRole 'sqlRoleDefinitions' existing = { 48 | name: '00000000-0000-0000-0000-000000000002' 49 | } 50 | 51 | @description('Assign the project\'s managed identity the ability to read and write data in this collection within enterprise_memory database.') 52 | resource projectUserThreadContainerWriter 'sqlRoleAssignments' = { 53 | name: guid(aiFoundry::project.id, cosmosDbAccount::dataContributorRole.id, 'enterprise_memory', 'user') 54 | properties: { 55 | roleDefinitionId: cosmosDbAccount::dataContributorRole.id 56 | principalId: aiFoundry::project.identity.principalId 57 | scope: scopeUserContainerId 58 | } 59 | dependsOn: [ 60 | aiFoundry::project::aiAgentService 61 | ] 62 | } 63 | 64 | @description('Assign the project\'s managed identity the ability to read and write data in this collection within enterprise_memory database.') 65 | resource projectSystemThreadContainerWriter 'sqlRoleAssignments' = { 66 | name: guid(aiFoundry::project.id, cosmosDbAccount::dataContributorRole.id, 'enterprise_memory', 'system') 67 | properties: { 68 | roleDefinitionId: cosmosDbAccount::dataContributorRole.id 69 | principalId: aiFoundry::project.identity.principalId 70 | scope: scopeSystemContainerId 71 | } 72 | dependsOn: [ 73 | cosmosDbAccount::projectUserThreadContainerWriter // Single thread applying these permissions. 74 | aiFoundry::project::aiAgentService 75 | ] 76 | } 77 | 78 | @description('Assign the project\'s managed identity the ability to read and write data in this collection within enterprise_memory database.') 79 | resource projectEntityContainerWriter 'sqlRoleAssignments' = { 80 | name: guid(aiFoundry::project.id, cosmosDbAccount::dataContributorRole.id, 'enterprise_memory', 'entities') 81 | properties: { 82 | roleDefinitionId: cosmosDbAccount::dataContributorRole.id 83 | principalId: aiFoundry::project.identity.principalId 84 | scope: scopeEntityContainerId 85 | } 86 | dependsOn: [ 87 | cosmosDbAccount::projectSystemThreadContainerWriter // Single thread applying these permissions. 88 | aiFoundry::project::aiAgentService 89 | ] 90 | } 91 | } 92 | 93 | resource agentStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { 94 | name: existingStorageAccountName 95 | } 96 | 97 | resource azureAISearchService 'Microsoft.Search/searchServices@2025-02-01-preview' existing = { 98 | name: existingAISearchAccountName 99 | } 100 | 101 | resource azureAISearchServiceContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 102 | name: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' 103 | scope: subscription() 104 | } 105 | 106 | resource azureAISearchIndexDataContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 107 | name: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' 108 | scope: subscription() 109 | } 110 | 111 | // Storage Blob Data Contributor 112 | resource storageBlobDataContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 113 | name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' 114 | scope: subscription() 115 | } 116 | 117 | // Storage Blob Data Owner Role 118 | resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 119 | name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' 120 | scope: subscription() 121 | } 122 | 123 | resource cosmosDbOperatorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 124 | name: '230815da-be43-4aae-9cb4-875f7bd000aa' 125 | scope: subscription() 126 | } 127 | 128 | #disable-next-line BCP081 129 | resource bingAccount 'Microsoft.Bing/accounts@2025-05-01-preview' existing = { 130 | name: existingBingAccountName 131 | } 132 | 133 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { 134 | name: existingWebApplicationInsightsResourceName 135 | } 136 | 137 | // ---- New resources ---- 138 | 139 | @description('Existing Azure AI Foundry account. The project will be created as a child resource of this account.') 140 | resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { 141 | name: existingAiFoundryName 142 | 143 | resource project 'projects' = { 144 | name: 'projchat' 145 | location: location 146 | identity: { 147 | type: 'SystemAssigned' 148 | } 149 | properties: { 150 | description: 'Chat using internet data in your Azure AI Agent.' 151 | displayName: 'Chat with Internet Data' 152 | } 153 | 154 | @description('Create project connection to CosmosDB (thread storage); dependency for Azure AI Agent service.') 155 | resource threadStorageConnection 'connections' = { 156 | name: cosmosDbAccount.name 157 | properties: { 158 | authType: 'AAD' 159 | category: 'CosmosDb' 160 | target: cosmosDbAccount.properties.documentEndpoint 161 | metadata: { 162 | ApiType: 'Azure' 163 | ResourceId: cosmosDbAccount.id 164 | location: cosmosDbAccount.location 165 | } 166 | } 167 | dependsOn: [ 168 | projectDbCosmosDbOperatorAssignment 169 | ] 170 | } 171 | 172 | @description('Create project connection to the Azure Storage account; dependency for Azure AI Agent service.') 173 | resource storageConnection 'connections' = { 174 | name: agentStorageAccount.name 175 | properties: { 176 | authType: 'AAD' 177 | category: 'AzureStorageAccount' 178 | target: agentStorageAccount.properties.primaryEndpoints.blob 179 | metadata: { 180 | ApiType: 'Azure' 181 | ResourceId: agentStorageAccount.id 182 | location: agentStorageAccount.location 183 | } 184 | } 185 | dependsOn: [ 186 | projectBlobDataContributorAssignment 187 | projectBlobDataOwnerConditionalAssignment 188 | threadStorageConnection // Single thread these connections, else conflict errors tend to happen 189 | ] 190 | } 191 | 192 | @description('Create project connection to Azure AI Search; dependency for Azure AI Agent service.') 193 | resource aiSearchConnection 'connections' = { 194 | name: azureAISearchService.name 195 | properties: { 196 | category: 'CognitiveSearch' 197 | target: azureAISearchService.properties.endpoint 198 | authType: 'AAD' 199 | metadata: { 200 | ApiType: 'Azure' 201 | ResourceId: azureAISearchService.id 202 | location: azureAISearchService.location 203 | } 204 | } 205 | dependsOn: [ 206 | projectAISearchIndexDataContributorAssignment 207 | projectAISearchContributorAssignment 208 | storageConnection // Single thread these connections, else conflict errors tend to happen 209 | ] 210 | } 211 | 212 | @description('Connect this project to application insights for visualization of token usage.') 213 | resource applicationInsightsConnection 'connections' = { 214 | name:'appInsights-connection' 215 | properties: { 216 | authType: 'ApiKey' 217 | category: 'AppInsights' 218 | credentials: { 219 | key: applicationInsights.properties.ConnectionString 220 | } 221 | isSharedToAll: false 222 | target: applicationInsights.id 223 | metadata: { 224 | ApiType: 'Azure' 225 | ResourceId: applicationInsights.id 226 | location: applicationInsights.location 227 | } 228 | } 229 | dependsOn: [ 230 | aiSearchConnection // Single thread these connections, else conflict errors tend to happen 231 | ] 232 | } 233 | 234 | 235 | @description('Create the Azure AI Agent service.') 236 | resource aiAgentService 'capabilityHosts' = { 237 | name: 'projectagents' 238 | properties: { 239 | capabilityHostKind: 'Agents' 240 | vectorStoreConnections: ['${aiSearchConnection.name}'] 241 | storageConnections: ['${storageConnection.name}'] 242 | threadStorageConnections: ['${threadStorageConnection.name}'] 243 | } 244 | dependsOn: [ 245 | applicationInsightsConnection // Single thread changes to the project, else conflict errors tend to happen 246 | ] 247 | } 248 | 249 | @description('Create project connection to Bing grounding data. Useful for future agents that get created.') 250 | resource bingGroundingConnection 'connections' = { 251 | name: replace(existingBingAccountName, '-', '') 252 | properties: { 253 | authType: 'ApiKey' 254 | target: bingAccount.properties.endpoint 255 | category: 'GroundingWithBingSearch' 256 | metadata: { 257 | type: 'bing_grounding' 258 | ApiType: 'Azure' 259 | ResourceId: bingAccount.id 260 | location: bingAccount.location 261 | } 262 | credentials: { 263 | key: bingAccount.listKeys().key1 264 | } 265 | isSharedToAll: false 266 | } 267 | dependsOn: [ 268 | aiAgentService // Deploy after the Azure AI Agent service is provisioned, not a dependency. 269 | ] 270 | } 271 | } 272 | } 273 | 274 | // Role assignments 275 | 276 | resource projectDbCosmosDbOperatorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 277 | name: guid(aiFoundry::project.id, cosmosDbOperatorRole.id, cosmosDbAccount.id) 278 | scope: cosmosDbAccount 279 | properties: { 280 | roleDefinitionId: cosmosDbOperatorRole.id 281 | principalId: aiFoundry::project.identity.principalId 282 | principalType: 'ServicePrincipal' 283 | } 284 | } 285 | 286 | resource projectBlobDataContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 287 | name: guid(aiFoundry::project.id, storageBlobDataContributorRole.id, agentStorageAccount.id) 288 | scope: agentStorageAccount 289 | properties: { 290 | roleDefinitionId: storageBlobDataContributorRole.id 291 | principalId: aiFoundry::project.identity.principalId 292 | principalType: 'ServicePrincipal' 293 | } 294 | } 295 | 296 | resource projectBlobDataOwnerConditionalAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 297 | name: guid(aiFoundry::project.id, storageBlobDataOwnerRole.id, agentStorageAccount.id) 298 | scope: agentStorageAccount 299 | properties: { 300 | principalId: aiFoundry::project.identity.principalId 301 | roleDefinitionId: storageBlobDataOwnerRole.id 302 | principalType: 'ServicePrincipal' 303 | conditionVersion: '2.0' 304 | condition: '((!(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action\'}) AND !(ActionMatches{\'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write\'}) ) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase \'${workspaceIdAsGuid}\'))' 305 | } 306 | } 307 | 308 | resource projectAISearchContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 309 | name: guid(aiFoundry::project.id, azureAISearchServiceContributorRole.id, azureAISearchService.id) 310 | scope: azureAISearchService 311 | properties: { 312 | roleDefinitionId: azureAISearchServiceContributorRole.id 313 | principalId: aiFoundry::project.identity.principalId 314 | principalType: 'ServicePrincipal' 315 | } 316 | } 317 | 318 | resource projectAISearchIndexDataContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 319 | name: guid(aiFoundry::project.id, azureAISearchIndexDataContributorRole.id, azureAISearchService.id) 320 | scope: azureAISearchService 321 | properties: { 322 | roleDefinitionId: azureAISearchIndexDataContributorRole.id 323 | principalId: aiFoundry::project.identity.principalId 324 | principalType: 'ServicePrincipal' 325 | } 326 | } 327 | 328 | // ---- Outputs ---- 329 | 330 | output aiAgentProjectName string = aiFoundry::project.name 331 | -------------------------------------------------------------------------------- /infra-as-code/bicep/ai-foundry.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The name of the workload\'s existing Log Analytics workspace.') 13 | @minLength(4) 14 | param logAnalyticsWorkspaceName string 15 | 16 | @description('The resource ID for the subnet that the Azure AI Agents will egress through.') 17 | @minLength(1) 18 | param agentSubnetResourceId string 19 | 20 | @description('The resource ID for the subnet that private endpoints in the workload should surface in.') 21 | @minLength(1) 22 | param privateEndpointSubnetResourceId string 23 | 24 | @description('Your principal ID. Allows you to access the Azure AI Foundry portal for post-deployment verification of functionality.') 25 | @maxLength(36) 26 | @minLength(36) 27 | param aiFoundryPortalUserPrincipalId string 28 | 29 | var aiFoundryName = 'aif${baseName}' 30 | 31 | // ---- Existing resources ---- 32 | 33 | @description('Existing: Private DNS zone for Azure AI services using the cognitive services FQDN.') 34 | resource cognitiveServicesLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 35 | name: 'privatelink.cognitiveservices.azure.com' 36 | } 37 | 38 | @description('Existing: Private DNS zone for Azure AI services using the Azure AI services FQDN.') 39 | resource aiFoundryLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 40 | name: 'privatelink.services.ai.azure.com' 41 | } 42 | 43 | @description('Existing: Private DNS zone for Azure AI services using the Azure AI OpenAI FQDN.') 44 | resource azureOpenAiLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 45 | name: 'privatelink.openai.azure.com' 46 | } 47 | 48 | @description('Existing: Built-in Cognitive Services User role.') 49 | resource cognitiveServicesUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 50 | name: 'a97b65f3-24c7-4388-baec-2e87135dc908' 51 | scope: subscription() 52 | } 53 | 54 | @description('Existing: Log sink for Azure Diagnostics.') 55 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 56 | name: logAnalyticsWorkspaceName 57 | } 58 | 59 | // ---- New resources ---- 60 | 61 | @description('Deploy Azure AI Foundry (account) with Azure AI Agent service capability.') 62 | resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { 63 | name: aiFoundryName 64 | location: location 65 | kind: 'AIServices' 66 | sku: { 67 | name: 'S0' 68 | } 69 | identity: { 70 | type: 'SystemAssigned' 71 | } 72 | properties: { 73 | customSubDomainName: aiFoundryName 74 | allowProjectManagement: true // Azure AI Foundry account 75 | disableLocalAuth: true 76 | networkAcls: { 77 | bypass: 'None' 78 | ipRules: [] 79 | defaultAction: 'Deny' 80 | virtualNetworkRules: [] 81 | } 82 | publicNetworkAccess: 'Disabled' 83 | #disable-next-line BCP036 84 | networkInjections: [ 85 | { 86 | scenario: 'agent' 87 | subnetArmId: agentSubnetResourceId // Report this, schema issue and IP address range issue 88 | useMicrosoftManagedNetwork: false 89 | } 90 | ] 91 | } 92 | 93 | @description('Models are managed at the account level. Deploy the GPT model that will be used for the Azure AI Agent logic.') 94 | resource model 'deployments' = { 95 | name: 'agent-model' 96 | sku: { 97 | capacity: 50 98 | name: 'DataZoneStandard' // Production readiness, use provisioned deployments with automatic spillover https://learn.microsoft.com/azure/ai-services/openai/how-to/spillover-traffic-management. 99 | } 100 | properties: { 101 | model: { 102 | format: 'OpenAI' 103 | name: 'gpt-4o' 104 | version: '2024-11-20' // Use a model version available in your region. 105 | } 106 | versionUpgradeOption: 'NoAutoUpgrade' // Production deployments should not auto-upgrade models. Testing compatibility is important. 107 | raiPolicyName: 'Microsoft.DefaultV2' // If this isn't strict enough for your use case, create a custom RAI policy. 108 | } 109 | } 110 | } 111 | 112 | // Role assignments 113 | 114 | @description('Assign yourself to have access to the Azure AI Foundry portal.') 115 | resource cognitiveServicesUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 116 | name: guid(aiFoundry.id, cognitiveServicesUserRole.id, aiFoundryPortalUserPrincipalId) 117 | scope: aiFoundry 118 | properties: { 119 | roleDefinitionId: cognitiveServicesUserRole.id 120 | principalId: aiFoundryPortalUserPrincipalId 121 | principalType: 'User' 122 | } 123 | } 124 | 125 | // Private endpoints 126 | 127 | @description('Connect the Azure AI Foundry account\'s endpoints to your existing private DNS zones.') 128 | resource aiFoundryPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 129 | name: 'pe-ai-foundry' 130 | location: location 131 | properties: { 132 | subnet: { 133 | id: privateEndpointSubnetResourceId 134 | } 135 | customNetworkInterfaceName: 'nic-ai-foundry' 136 | privateLinkServiceConnections: [ 137 | { 138 | name: 'aifoundry' 139 | properties: { 140 | privateLinkServiceId: aiFoundry.id 141 | groupIds: [ 142 | 'account' 143 | ] 144 | } 145 | } 146 | ] 147 | } 148 | 149 | resource dnsGroup 'privateDnsZoneGroups' = { 150 | name: 'aifoundry' 151 | properties: { 152 | privateDnsZoneConfigs: [ 153 | { 154 | name: 'aifoundry' 155 | properties: { 156 | privateDnsZoneId: aiFoundryLinkedPrivateDnsZone.id 157 | } 158 | } 159 | { 160 | name: 'azureopenai' 161 | properties: { 162 | privateDnsZoneId: azureOpenAiLinkedPrivateDnsZone.id 163 | } 164 | } 165 | { 166 | name: 'cognitiveservices' 167 | properties: { 168 | privateDnsZoneId: cognitiveServicesLinkedPrivateDnsZone.id 169 | } 170 | } 171 | ] 172 | } 173 | } 174 | } 175 | 176 | // Azure diagnostics 177 | 178 | @description('Enable logging on the Azure AI Foundry account.') 179 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 180 | name: 'default' 181 | scope: aiFoundry 182 | properties: { 183 | workspaceId: logAnalyticsWorkspace.id 184 | logs: [ 185 | { 186 | category: 'Audit' 187 | enabled: true 188 | retentionPolicy: { 189 | enabled: false 190 | days: 0 191 | } 192 | } 193 | { 194 | category: 'RequestResponse' 195 | enabled: true 196 | retentionPolicy: { 197 | enabled: false 198 | days: 0 199 | } 200 | } 201 | { 202 | category: 'AzureOpenAIRequestUsage' 203 | enabled: true 204 | retentionPolicy: { 205 | enabled: false 206 | days: 0 207 | } 208 | } 209 | { 210 | category: 'Trace' 211 | enabled: true 212 | retentionPolicy: { 213 | enabled: false 214 | days: 0 215 | } 216 | } 217 | ] 218 | } 219 | } 220 | 221 | // ---- Outputs ---- 222 | 223 | @description('The name of the Azure AI Foundry account.') 224 | output aiFoundryName string = aiFoundry.name 225 | -------------------------------------------------------------------------------- /infra-as-code/bicep/ai-search.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The name of the workload\'s existing Log Analytics workspace.') 13 | @minLength(4) 14 | param logAnalyticsWorkspaceName string 15 | 16 | @description('The resource ID for the subnet that private endpoints in the workload should surface in.') 17 | @minLength(1) 18 | param privateEndpointSubnetResourceId string 19 | 20 | @description('Assign your user some roles to support access to the Azure AI Agent dependencies for troubleshooting post deployment') 21 | @maxLength(36) 22 | @minLength(36) 23 | param debugUserPrincipalId string 24 | 25 | // ---- Existing resources ---- 26 | 27 | resource aiSearchLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 28 | name: 'privatelink.search.windows.net' 29 | } 30 | 31 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 32 | name: logAnalyticsWorkspaceName 33 | } 34 | 35 | resource azureAISearchIndexDataContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 36 | name: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' 37 | scope: subscription() 38 | } 39 | 40 | // ---- New resources ---- 41 | 42 | resource azureAiSearchService 'Microsoft.Search/searchServices@2025-02-01-preview' = { 43 | name: 'ais-ai-agent-vector-store-${baseName}' 44 | location: location 45 | identity: { 46 | type: 'SystemAssigned' 47 | } 48 | sku: { 49 | name: 'standard' 50 | } 51 | properties: { 52 | disableLocalAuth: true 53 | authOptions: null 54 | hostingMode: 'default' 55 | partitionCount: 1 // Production readiness change: This can be updated based on the expected data volume and query load. 56 | replicaCount: 3 // 3 replicas are required for 99.9% availability for read/write operations 57 | semanticSearch: 'disabled' 58 | publicNetworkAccess: 'disabled' 59 | networkRuleSet: { 60 | bypass: 'None' 61 | ipRules: [] 62 | } 63 | } 64 | } 65 | 66 | // Role assignments 67 | 68 | @description('Assign your user the Azure AI Search Index Data Contributor role to support troubleshooting post deployment. Not needed for normal operation.') 69 | resource debugUserAISearchIndexDataContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 70 | name: guid(debugUserPrincipalId, azureAISearchIndexDataContributorRole.id, azureAiSearchService.id) 71 | scope: azureAiSearchService 72 | properties: { 73 | roleDefinitionId: azureAISearchIndexDataContributorRole.id 74 | principalId: debugUserPrincipalId 75 | principalType: 'User' 76 | } 77 | } 78 | 79 | // Azure diagnostics 80 | 81 | @description('Capture Azure Diagnostics for the Azure AI Search Service.') 82 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 83 | name: 'default' 84 | scope: azureAiSearchService 85 | properties: { 86 | workspaceId: logAnalyticsWorkspace.id 87 | logs: [ 88 | { 89 | category: 'OperationLogs' 90 | enabled: true 91 | retentionPolicy: { 92 | enabled: false 93 | days: 0 94 | } 95 | } 96 | ] 97 | } 98 | } 99 | 100 | // Private endpoints 101 | 102 | resource aiSearchPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 103 | name: 'pe-ai-agent-search' 104 | location: location 105 | properties: { 106 | subnet: { 107 | id: privateEndpointSubnetResourceId 108 | } 109 | customNetworkInterfaceName: 'nic-ai-agent-search' 110 | privateLinkServiceConnections: [ 111 | { 112 | name: 'ai-agent-search' 113 | properties: { 114 | privateLinkServiceId: azureAiSearchService.id 115 | groupIds: [ 116 | 'searchService' 117 | ] 118 | } 119 | } 120 | ] 121 | } 122 | 123 | resource dnsGroup 'privateDnsZoneGroups' = { 124 | name: 'ai-agent-search' 125 | properties: { 126 | privateDnsZoneConfigs: [ 127 | { 128 | name: 'ai-agent-search' 129 | properties: { 130 | privateDnsZoneId: aiSearchLinkedPrivateDnsZone.id 131 | } 132 | } 133 | ] 134 | } 135 | } 136 | } 137 | 138 | // ---- Outputs ---- 139 | 140 | output aiSearchName string = azureAiSearchService.name 141 | -------------------------------------------------------------------------------- /infra-as-code/bicep/application-gateway.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | /* 4 | Deploy an Azure Application Gateway with WAF v2 and a custom domain name. 5 | */ 6 | 7 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 8 | @minLength(1) 9 | param location string = resourceGroup().location 10 | 11 | @description('This is the base name for each Azure resource name (6-8 chars)') 12 | @minLength(6) 13 | @maxLength(8) 14 | param baseName string 15 | 16 | @description('The name of the workload\'s existing Log Analytics workspace.') 17 | @minLength(4) 18 | param logAnalyticsWorkspaceName string 19 | 20 | @description('Domain name to use for App Gateway') 21 | param customDomainName string 22 | 23 | @description('The name of the existing virtual network that this Application Gateway instance will be deployed into.') 24 | @minLength(1) 25 | param virtualNetworkName string 26 | 27 | @description('The name of the existing subnet for Application Gateway. Must in in the provided virtual network and sized appropriately.') 28 | param applicationGatewaySubnetName string 29 | 30 | @description('The name of the existing webapp that will be the backend origin for the primary Application Gateway route.') 31 | param appName string 32 | 33 | @description('The name of the existing Key Vault that contains the SSL certificate for the Application Gateway.') 34 | param keyVaultName string 35 | 36 | @description('The name of the existing Key Vault secret that contains the SSL certificate for the Application Gateway.') 37 | #disable-next-line secure-secrets-in-params 38 | param gatewayCertSecretKey string 39 | 40 | //variables 41 | var appGatewayName = 'agw-${baseName}' 42 | var appGatewayManagedIdentityName = 'id-${appGatewayName}' 43 | var appGatewayPublicIpName = 'pip-${baseName}' 44 | var appGatewayFqdn = 'fe-${baseName}' 45 | var wafPolicyName= 'waf-${baseName}' 46 | 47 | // ---- Existing resources ---- 48 | 49 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 50 | name: virtualNetworkName 51 | 52 | resource applicationGatewaySubnet 'subnets' existing = { 53 | name: applicationGatewaySubnetName 54 | } 55 | } 56 | 57 | resource webApp 'Microsoft.Web/sites@2024-04-01' existing = { 58 | name: appName 59 | } 60 | 61 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 62 | name: logAnalyticsWorkspaceName 63 | } 64 | 65 | resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { 66 | name: keyVaultName 67 | 68 | resource kvsGatewayPublicCert 'secrets' existing = { 69 | name: gatewayCertSecretKey 70 | } 71 | } 72 | 73 | @description('Built-in Role: [Key Vault Secrets User](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-secrets-user)') 74 | resource keyVaultSecretsUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 75 | name: '4633458b-17de-408a-b874-0445c86b69e6' 76 | scope: subscription() 77 | } 78 | 79 | // ---- New resources ---- 80 | 81 | // Managed Identity for App Gateway. 82 | resource appGatewayManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { 83 | name: appGatewayManagedIdentityName 84 | location: location 85 | } 86 | 87 | @description('Grant the Application Gateway managed identity Key Vault secrets user role permissions. This allows pulling certificates.') 88 | module grantAppGatewaySecretsUserRoleAssignment './modules/keyvaultRoleAssignment.bicep' = { 89 | name: 'appGatewaySecretsUserRoleAssignmentDeploy' 90 | params: { 91 | roleDefinitionId: keyVaultSecretsUserRole.id 92 | principalId: appGatewayManagedIdentity.properties.principalId 93 | keyVaultName: keyVaultName 94 | } 95 | } 96 | 97 | //External IP for App Gateway 98 | resource appGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { 99 | name: appGatewayPublicIpName 100 | location: location 101 | zones: pickZones('Microsoft.Network', 'publicIPAddresses', location, 3) 102 | sku: { 103 | name: 'Standard' 104 | } 105 | properties: { 106 | publicIPAddressVersion: 'IPv4' 107 | publicIPAllocationMethod: 'Static' 108 | idleTimeoutInMinutes: 4 109 | dnsSettings: { 110 | domainNameLabel: appGatewayFqdn 111 | } 112 | } 113 | } 114 | 115 | //WAF policy definition 116 | resource wafPolicy 'Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies@2024-05-01' = { 117 | name: wafPolicyName 118 | location: location 119 | properties: { 120 | policySettings: { 121 | requestBodyCheck: true 122 | requestBodyEnforcement: true 123 | maxRequestBodySizeInKb: 128 124 | requestBodyInspectLimitInKB: 128 125 | fileUploadLimitInMb: 10 126 | fileUploadEnforcement: true 127 | jsChallengeCookieExpirationInMins: 30 128 | state: 'Enabled' 129 | mode: 'Prevention' 130 | } 131 | managedRules: { 132 | managedRuleSets: [ 133 | { 134 | ruleSetType: 'Microsoft_DefaultRuleSet' 135 | ruleSetVersion: '2.1' 136 | ruleGroupOverrides: [] 137 | } 138 | { 139 | ruleSetType: 'Microsoft_BotManagerRuleSet' 140 | ruleSetVersion: '1.1' 141 | ruleGroupOverrides: [] 142 | } 143 | ] 144 | } 145 | } 146 | } 147 | 148 | //App Gateway 149 | resource appGateway 'Microsoft.Network/applicationGateways@2024-05-01' = { 150 | name: appGatewayName 151 | location: location 152 | zones: pickZones('Microsoft.Network', 'applicationGateways', location, 3) 153 | identity: { 154 | type: 'UserAssigned' 155 | userAssignedIdentities: { 156 | '${appGatewayManagedIdentity.id}': {} 157 | } 158 | } 159 | properties: { 160 | sku: { 161 | name: 'WAF_v2' 162 | tier: 'WAF_v2' 163 | } 164 | sslPolicy: { 165 | policyType: 'Custom' 166 | cipherSuites: [ 167 | 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384' 168 | 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256' 169 | ] 170 | minProtocolVersion: 'TLSv1_2' 171 | } 172 | 173 | gatewayIPConfigurations: [ 174 | { 175 | name: 'appGatewayIpConfig' 176 | properties: { 177 | subnet: { 178 | id: virtualNetwork::applicationGatewaySubnet.id 179 | } 180 | } 181 | } 182 | ] 183 | frontendIPConfigurations: [ 184 | { 185 | name: 'appGwPublicFrontendIp' 186 | properties: { 187 | privateIPAllocationMethod: 'Dynamic' 188 | publicIPAddress: { 189 | id: appGatewayPublicIp.id 190 | } 191 | } 192 | } 193 | ] 194 | frontendPorts: [ 195 | { 196 | name: 'port-443' 197 | properties: { 198 | port: 443 199 | } 200 | } 201 | ] 202 | probes: [ 203 | { 204 | name: 'probe-web${baseName}' 205 | properties: { 206 | protocol: 'Https' 207 | path: '/favicon.ico' 208 | interval: 30 209 | timeout: 30 210 | unhealthyThreshold: 3 211 | pickHostNameFromBackendHttpSettings: true 212 | minServers: 0 213 | match: { 214 | statusCodes: [ 215 | '200-399' 216 | '401' 217 | '403' 218 | ] 219 | } 220 | } 221 | } 222 | ] 223 | firewallPolicy: { 224 | id: wafPolicy.id 225 | } 226 | enableHttp2: true 227 | sslCertificates: [ 228 | { 229 | name: '${appGatewayName}-ssl-certificate' 230 | properties: { 231 | keyVaultSecretId: keyVault::kvsGatewayPublicCert.properties.secretUri 232 | } 233 | } 234 | ] 235 | backendAddressPools: [ 236 | { 237 | name: 'pool-${appName}' 238 | properties: { 239 | backendAddresses: [ 240 | { 241 | fqdn: webApp.properties.defaultHostName 242 | } 243 | ] 244 | } 245 | } 246 | ] 247 | backendHttpSettingsCollection: [ 248 | { 249 | name: 'WebAppBackendHttpSettings' 250 | properties: { 251 | port: 443 252 | protocol: 'Https' 253 | cookieBasedAffinity: 'Disabled' 254 | pickHostNameFromBackendAddress: true 255 | requestTimeout: 20 256 | probe: { 257 | id: resourceId('Microsoft.Network/applicationGateways/probes', appGatewayName, 'probe-web${baseName}') 258 | } 259 | } 260 | } 261 | ] 262 | httpListeners: [ 263 | { 264 | name: 'WebAppListener' 265 | properties: { 266 | frontendIPConfiguration: { 267 | id: resourceId('Microsoft.Network/applicationGateways/frontendIPConfigurations', appGatewayName, 'appGwPublicFrontendIp') 268 | } 269 | frontendPort: { 270 | id: resourceId('Microsoft.Network/applicationGateways/frontendPorts', appGatewayName, 'port-443') 271 | } 272 | protocol: 'Https' 273 | sslCertificate: { 274 | id: resourceId('Microsoft.Network/applicationGateways/sslCertificates', appGatewayName, '${appGatewayName}-ssl-certificate') 275 | } 276 | hostName: 'www.${customDomainName}' 277 | hostNames: [] 278 | requireServerNameIndication: true 279 | } 280 | } 281 | ] 282 | requestRoutingRules: [ 283 | { 284 | name: 'WebAppRoutingRule' 285 | properties: { 286 | ruleType: 'Basic' 287 | priority: 100 288 | httpListener: { 289 | id: resourceId('Microsoft.Network/applicationGateways/httpListeners', appGatewayName, 'WebAppListener') 290 | } 291 | backendAddressPool: { 292 | id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools', appGatewayName, 'pool-${appName}') 293 | } 294 | backendHttpSettings: { 295 | id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection', appGatewayName, 'WebAppBackendHttpSettings') 296 | } 297 | } 298 | } 299 | ] 300 | autoscaleConfiguration: { 301 | minCapacity: 2 302 | maxCapacity: 5 303 | } 304 | } 305 | dependsOn: [ 306 | grantAppGatewaySecretsUserRoleAssignment 307 | ] 308 | } 309 | 310 | @description('Enable Application Gateway diagnostic settings') 311 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 312 | name: 'default' 313 | scope: appGateway 314 | properties: { 315 | workspaceId: logWorkspace.id 316 | logs: [ 317 | { 318 | category: 'ApplicationGatewayAccessLog' 319 | enabled: true 320 | retentionPolicy: { 321 | enabled: false 322 | days: 0 323 | } 324 | } 325 | { 326 | category: 'ApplicationGatewayPerformanceLog' 327 | enabled: true 328 | retentionPolicy: { 329 | enabled: false 330 | days: 0 331 | } 332 | } 333 | { 334 | category: 'ApplicationGatewayFirewallLog' 335 | enabled: true 336 | retentionPolicy: { 337 | enabled: false 338 | days: 0 339 | } 340 | } 341 | ] 342 | logAnalyticsDestinationType: null 343 | } 344 | } 345 | 346 | // ---- Outputs ---- 347 | 348 | @description('The name of the Azure Application Gateway resource.') 349 | output applicationGatewayName string = appGateway.name 350 | -------------------------------------------------------------------------------- /infra-as-code/bicep/application-insights.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The name of the workload\'s existing Log Analytics workspace.') 13 | @minLength(4) 14 | param logAnalyticsWorkspaceName string 15 | 16 | // ---- Existing resources ---- 17 | 18 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 19 | name: logAnalyticsWorkspaceName 20 | } 21 | 22 | // ---- New resources ---- 23 | 24 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { 25 | name: 'appi-${baseName}' 26 | location: location 27 | kind: 'web' 28 | properties: { 29 | Application_Type: 'web' 30 | WorkspaceResourceId: logAnalyticsWorkspace.id 31 | RetentionInDays: 90 32 | IngestionMode: 'LogAnalytics' 33 | publicNetworkAccessForIngestion: 'Enabled' 34 | publicNetworkAccessForQuery: 'Enabled' 35 | } 36 | } 37 | 38 | // ---- Outputs ---- 39 | 40 | output applicationInsightsName string = applicationInsights.name 41 | -------------------------------------------------------------------------------- /infra-as-code/bicep/azure-firewall.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('The name of the workload\'s virtual network in this resource group. Azure Firewall and it\'s management NIC will be deployed into this network.') 8 | @minLength(1) 9 | param virtualNetworkName string 10 | 11 | @description('The name of the workload\'s existing Log Analytics workspace.') 12 | @minLength(4) 13 | param logAnalyticsWorkspaceName string 14 | 15 | @description('The name of the subnet containing the Azure AI Agents. Must be in the same virtual network that is provided.') 16 | @minLength(8) 17 | param agentsEgressSubnetName string 18 | 19 | @description('The name of the subnet containing your jump boxes. Must be in the same virtual network that is provided.') 20 | @minLength(8) 21 | param jumpBoxesSubnetName string 22 | 23 | // ---- Existing resources ---- 24 | 25 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 26 | name: logAnalyticsWorkspaceName 27 | } 28 | 29 | @description('Existing virtual network.') 30 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 31 | name: virtualNetworkName 32 | 33 | resource agentsEgressSubnet 'subnets' existing = { 34 | name: agentsEgressSubnetName 35 | } 36 | 37 | resource jumpBoxesSubnet 'subnets' existing = { 38 | name: jumpBoxesSubnetName 39 | } 40 | 41 | resource firewallManagementSubnet 'subnets' existing = { 42 | name: 'AzureFirewallManagementSubnet' 43 | } 44 | 45 | resource firewall 'subnets' existing = { 46 | name: 'AzureFirewallSubnet' 47 | } 48 | } 49 | 50 | // ---- New resources ---- 51 | 52 | @description('The public IP address for all traffic egressing from the firewall. You can add more addresses if needed to reduce the chance for port exhaustion.') 53 | resource publicIpForAzureFirewallEgress 'Microsoft.Network/publicIPAddresses@2024-05-01' = { 54 | name: 'pip-firewall-egress-00' 55 | location: location 56 | sku: { 57 | name: 'Standard' 58 | tier: 'Regional' 59 | } 60 | zones: pickZones('Microsoft.Network', 'publicIPAddresses', location, 3) 61 | properties: { 62 | publicIPAddressVersion: 'IPv4' 63 | publicIPAllocationMethod: 'Static' 64 | idleTimeoutInMinutes: 4 65 | } 66 | } 67 | 68 | @description('The public IP address for the Azure Firewall control plane.') 69 | resource publicIpForAzureFirewallManagement 'Microsoft.Network/publicIPAddresses@2024-05-01' = { 70 | name: 'pip-firewall-mgmt-00' 71 | location: location 72 | sku: { 73 | name: 'Standard' 74 | tier: 'Regional' 75 | } 76 | zones: pickZones('Microsoft.Network', 'publicIPAddresses', location, 3) 77 | properties: { 78 | publicIPAddressVersion: 'IPv4' 79 | publicIPAllocationMethod: 'Static' 80 | idleTimeoutInMinutes: 4 81 | } 82 | } 83 | 84 | @description('The firewall rules assigned to our egress firewall.') 85 | resource azureFirewallPolicy 'Microsoft.Network/firewallPolicies@2024-05-01' = { 86 | name: 'fw-egress-policy' 87 | location: location 88 | properties: { 89 | sku: { 90 | tier: 'Basic' 91 | } 92 | threatIntelMode: 'Alert' 93 | } 94 | 95 | @description('Add rules for the jump boxes subnet. Extend to support other subnets as needed.') 96 | resource networkRules 'ruleCollectionGroups' = { 97 | name: 'DefaultNetworkRuleCollectionGroup' 98 | properties: { 99 | priority: 200 100 | ruleCollections: [ 101 | { 102 | ruleCollectionType: 'FirewallPolicyFilterRuleCollection' 103 | name: 'jump-box-egress' 104 | priority: 1000 105 | action: { 106 | type: 'Allow' 107 | } 108 | rules: [ 109 | { 110 | ruleType: 'NetworkRule' 111 | name: 'allow-dependencies' 112 | ipProtocols: ['Any'] 113 | sourceAddresses: ['${virtualNetwork::jumpBoxesSubnet.properties.addressPrefix}'] 114 | destinationAddresses: ['*'] // Production readiness change: tighten destination address to ensure egress traffic is restricted to the minimal required spaces. 115 | destinationPorts: ['*'] 116 | } 117 | ] 118 | } 119 | ] 120 | } 121 | } 122 | 123 | @descriptiou('Add rules for the Azure AI agent egress and jump boxes subnets. Extend to support other subnets as needed.') 124 | 125 | resource applicationRules 'ruleCollectionGroups' = { 126 | name: 'DefaultApplicationRuleCollectionGroup' 127 | properties: { 128 | priority: 300 129 | ruleCollections: [ 130 | { 131 | ruleCollectionType: 'FirewallPolicyFilterRuleCollection' 132 | name: 'agent-egress' 133 | priority: 1000 134 | action: { 135 | type: 'Allow' 136 | } 137 | rules: [ 138 | { 139 | ruleType: 'ApplicationRule' 140 | name: 'allow-dependencies' 141 | protocols: [ 142 | { 143 | protocolType: 'Https' 144 | port: 443 145 | } 146 | ] 147 | fqdnTags: [] 148 | webCategories: [] 149 | targetFqdns: [ 150 | '*' 151 | // 'api.bing.microsoft.com' // Production readiness change: refine your target FQDNs to restrict egress traffic exclusively to the external services and endpoints your agent depends on. For instance this fqnd scopes access specifically to Grounding with Bing. 152 | ] 153 | targetUrls: [] 154 | terminateTLS: false 155 | sourceAddresses: ['${virtualNetwork::agentsEgressSubnet.properties.addressPrefix}'] 156 | destinationAddresses: [] 157 | httpHeadersToInsert: [] 158 | } 159 | ] 160 | } 161 | { 162 | ruleCollectionType: 'FirewallPolicyFilterRuleCollection' 163 | name: 'jump-box-egress' 164 | priority: 1100 165 | action: { 166 | type: 'Allow' 167 | } 168 | rules: [ 169 | { 170 | ruleType: 'ApplicationRule' 171 | name: 'allow-dependencies' 172 | protocols: [ 173 | { 174 | protocolType: 'Https' 175 | port: 443 176 | } 177 | { 178 | protocolType: 'Http' 179 | port: 80 180 | } 181 | ] 182 | fqdnTags: [] 183 | webCategories: [] 184 | targetFqdns: ['*'] // Production readiness change: specify target FQDNs to ensure only approved resources can be accessed from your jumpbox. 185 | targetUrls: [] 186 | terminateTLS: false 187 | sourceAddresses: ['${virtualNetwork::jumpBoxesSubnet.properties.addressPrefix}'] 188 | destinationAddresses: [] 189 | httpHeadersToInsert: [] 190 | } 191 | ] 192 | } 193 | ] 194 | } 195 | dependsOn: [ 196 | networkRules 197 | ] 198 | } 199 | } 200 | 201 | @description('Our workload\'s egress firewall. This is used to control outbound traffic from the workload to the Internet.') 202 | resource azureFirewall 'Microsoft.Network/azureFirewalls@2024-05-01' = { 203 | name: 'fw-egress' 204 | location: location 205 | zones: pickZones('Microsoft.Network', 'azureFirewalls', location, 3) 206 | properties: { 207 | sku: { 208 | name: 'AZFW_VNet' 209 | tier: 'Basic' 210 | } 211 | threatIntelMode: 'Alert' 212 | additionalProperties: {} 213 | managementIpConfiguration: { 214 | name: publicIpForAzureFirewallManagement.name 215 | properties: { 216 | publicIPAddress: { 217 | id: publicIpForAzureFirewallManagement.id 218 | } 219 | subnet: { 220 | id: virtualNetwork::firewallManagementSubnet.id 221 | } 222 | } 223 | } 224 | ipConfigurations: [ 225 | { 226 | name: publicIpForAzureFirewallEgress.name 227 | properties: { 228 | publicIPAddress: { 229 | id: publicIpForAzureFirewallEgress.id 230 | } 231 | subnet: { 232 | id: virtualNetwork::firewall.id 233 | } 234 | } 235 | } 236 | ] 237 | firewallPolicy: { 238 | id: azureFirewallPolicy.id 239 | } 240 | } 241 | dependsOn: [ 242 | azureFirewallPolicy::applicationRules 243 | azureFirewallPolicy::networkRules 244 | ] 245 | } 246 | 247 | resource egressRouteTable 'Microsoft.Network/routeTables@2024-05-01' existing = { 248 | name: 'udr-internet-to-firewall' 249 | 250 | resource internetToFirewall 'routes' = { 251 | name: 'internet-to-firewall' 252 | properties: { 253 | addressPrefix: '0.0.0.0/0' 254 | nextHopType: 'VirtualAppliance' 255 | nextHopIpAddress: azureFirewall.properties.ipConfigurations[0].properties.privateIPAddress 256 | } 257 | } 258 | } 259 | 260 | // Azure diagnostics 261 | 262 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 263 | name: 'default' 264 | scope: azureFirewall 265 | properties: { 266 | workspaceId: logAnalyticsWorkspace.id 267 | logAnalyticsDestinationType: 'Dedicated' 268 | logs: [ 269 | { 270 | category: 'AzureFirewallApplicationRule' 271 | enabled: true 272 | retentionPolicy: { 273 | days: 0 274 | enabled: false 275 | } 276 | } 277 | { 278 | category: 'AzureFirewallNetworkRule' 279 | enabled: true 280 | retentionPolicy: { 281 | days: 0 282 | enabled: false 283 | } 284 | } 285 | { 286 | category: 'AzureFirewallDnsProxy' 287 | enabled: true 288 | retentionPolicy: { 289 | days: 0 290 | enabled: false 291 | } 292 | } 293 | { 294 | category: 'AZFWNetworkRule' 295 | enabled: true 296 | retentionPolicy: { 297 | days: 0 298 | enabled: false 299 | } 300 | } 301 | { 302 | category: 'AZFWApplicationRule' 303 | enabled: true 304 | retentionPolicy: { 305 | days: 0 306 | enabled: false 307 | } 308 | } 309 | { 310 | category: 'AZFWNatRule' 311 | enabled: true 312 | retentionPolicy: { 313 | days: 0 314 | enabled: false 315 | } 316 | } 317 | { 318 | category: 'AZFWThreatIntel' 319 | enabled: true 320 | retentionPolicy: { 321 | days: 0 322 | enabled: false 323 | } 324 | } 325 | { 326 | category: 'AZFWIdpsSignature' 327 | enabled: true 328 | retentionPolicy: { 329 | days: 0 330 | enabled: false 331 | } 332 | } 333 | { 334 | category: 'AZFWDnsQuery' 335 | enabled: true 336 | retentionPolicy: { 337 | days: 0 338 | enabled: false 339 | } 340 | } 341 | { 342 | category: 'AZFWFqdnResolveFailure' 343 | enabled: true 344 | retentionPolicy: { 345 | days: 0 346 | enabled: false 347 | } 348 | } 349 | { 350 | category: 'AZFWFatFlow' 351 | enabled: true 352 | retentionPolicy: { 353 | days: 0 354 | enabled: false 355 | } 356 | } 357 | { 358 | category: 'AZFWFlowTrace' 359 | enabled: true 360 | retentionPolicy: { 361 | days: 0 362 | enabled: false 363 | } 364 | } 365 | { 366 | category: 'AZFWApplicationRuleAggregation' 367 | enabled: true 368 | retentionPolicy: { 369 | days: 0 370 | enabled: false 371 | } 372 | } 373 | { 374 | category: 'AZFWNetworkRuleAggregation' 375 | enabled: true 376 | retentionPolicy: { 377 | days: 0 378 | enabled: false 379 | } 380 | } 381 | { 382 | category: 'AZFWNatRuleAggregation' 383 | enabled: true 384 | retentionPolicy: { 385 | days: 0 386 | enabled: false 387 | } 388 | } 389 | ] 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /infra-as-code/bicep/azure-policies.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | // Make sure the resource group has a few key Azure Policies applied to it. These could also be applied at the subscription 4 | // or management group level. Applying locally to the resource group is useful for testing and development purposes. 5 | 6 | // This is just a sampling of the types of policy you could apply to your resource group. Please make sure your production deployment 7 | // has all policies applied that are relevant to your workload. Most of these policies can be applied in 'Deny' mode, but in case you 8 | // need to troubleshoot some of the resources, we've left them in 'Audit' mode for now. 9 | 10 | @description('This is the base name for each Azure resource name (6-8 chars). It\'s used as a prefix in Azure Policy assignments') 11 | @minLength(6) 12 | @maxLength(8) 13 | param baseName string 14 | 15 | // Existing built-in policy definitions 16 | @description('Policy definition for ensuring Azure AI Services resources have key access disabled to improve security posture.') 17 | resource aiServicesKeyAccessPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 18 | name: '71ef260a-8f18-47b7-abcb-62d0673d94dc' 19 | scope: tenant() 20 | } 21 | 22 | @description('Policy definition for restricting network access to Azure AI Services resources to prevent unauthorized access.') 23 | resource aiServicesNetworkAccessPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 24 | name: '037eea7a-bd0a-46c5-9a66-03aea78705d3' 25 | scope: tenant() 26 | } 27 | 28 | @description('Policy definition for ensuring Cosmos DB accounts are configured with zone redundancy for high availability.') 29 | resource cosmosDbZoneRedundantPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 30 | name: '44c5a1f9-7ef6-4c38-880c-273e8f7a3c24' 31 | scope: tenant() 32 | } 33 | 34 | @description('Policy definition for ensuring Cosmos DB accounts use private endpoints for secure connectivity.') 35 | resource cosmosDbPrivateLinkPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 36 | name: '58440f8a-10c5-4151-bdce-dfbaad4a20b7' 37 | scope: tenant() 38 | } 39 | 40 | @description('Policy definition for disabling local authentication methods on Cosmos DB accounts to improve security.') 41 | resource cosmosDbDisableLocalAuthPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 42 | name: '5450f5bd-9c72-4390-a9c4-a7aba4edfdd2' 43 | scope: tenant() 44 | } 45 | 46 | @description('Policy definition for disabling public network access on Cosmos DB accounts to enhance security.') 47 | resource cosmosDbDisablePublicNetworkPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 48 | name: '797b37f7-06b8-444c-b1ad-fc62867f335a' 49 | scope: tenant() 50 | } 51 | 52 | @description('Policy definition for disabling public network access on Azure AI Search services to enhance security.') 53 | resource searchDisablePublicNetworkPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 54 | name: 'ee980b6d-0eca-4501-8d54-f6290fd512c3' 55 | scope: tenant() 56 | } 57 | 58 | @description('Policy definition for ensuring Azure AI Search services are configured with zone redundancy for high availability.') 59 | resource searchZoneRedundantPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 60 | name: '90bc8109-d21a-4692-88fc-51419391da3d' 61 | scope: tenant() 62 | } 63 | 64 | @description('Policy definition for disabling local authentication methods on Azure AI Search services to improve security.') 65 | resource searchDisableLocalAuthPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 66 | name: '6300012e-e9a4-4649-b41f-a85f5c43be91' 67 | scope: tenant() 68 | } 69 | 70 | @description('Policy definition for disabling public network access on Storage accounts to enhance security.') 71 | resource storageDisablePublicNetworkPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 72 | name: 'b2982f36-99f2-4db5-8eff-283140c09693' 73 | scope: tenant() 74 | } 75 | 76 | @description('Policy definition for preventing shared key access on Storage accounts to improve security posture.') 77 | resource storageDisableSharedKeyPolicy 'Microsoft.Authorization/policyDefinitions@2025-01-01' existing = { 78 | name: '8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54' 79 | scope: tenant() 80 | } 81 | 82 | // ---- New resources (Policy assignments) ---- 83 | 84 | @description('Policy assignment to audit Azure AI Services resources and ensure key access is disabled for enhanced security.') 85 | resource aiServicesKeyAccessAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 86 | name: guid(resourceGroup().id, aiServicesKeyAccessPolicy.id) 87 | scope: resourceGroup() 88 | properties: { 89 | displayName: '${baseName} - ${aiServicesKeyAccessPolicy.properties.displayName}' 90 | description: aiServicesKeyAccessPolicy.properties.description 91 | policyDefinitionId: aiServicesKeyAccessPolicy.id 92 | enforcementMode: 'Default' 93 | parameters: { 94 | effect: { 95 | value: 'Audit' 96 | } 97 | } 98 | } 99 | } 100 | 101 | @description('Policy assignment to audit and restrict network access for Azure AI Services resources to improve security posture.') 102 | resource aiServicesNetworkAccessAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 103 | name: guid(resourceGroup().id, aiServicesNetworkAccessPolicy.id) 104 | scope: resourceGroup() 105 | properties: { 106 | displayName: '${baseName} - ${aiServicesNetworkAccessPolicy.properties.displayName}' 107 | description: aiServicesNetworkAccessPolicy.properties.description 108 | policyDefinitionId: aiServicesNetworkAccessPolicy.id 109 | enforcementMode: 'Default' 110 | parameters: { 111 | effect: { 112 | value: 'Audit' 113 | } 114 | } 115 | } 116 | } 117 | 118 | @description('Policy assignment to audit Cosmos DB accounts and ensure zone redundancy is configured for high availability.') 119 | resource cosmosDbZoneRedundantAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 120 | name: guid(resourceGroup().id, cosmosDbZoneRedundantPolicy.id) 121 | scope: resourceGroup() 122 | properties: { 123 | displayName: '${baseName} - ${cosmosDbZoneRedundantPolicy.properties.displayName}' 124 | description: cosmosDbZoneRedundantPolicy.properties.description 125 | policyDefinitionId: cosmosDbZoneRedundantPolicy.id 126 | enforcementMode: 'Default' 127 | parameters: { 128 | effect: { 129 | value: 'Audit' 130 | } 131 | } 132 | } 133 | } 134 | 135 | @description('Policy assignment to audit Cosmos DB accounts and ensure they use private endpoints for secure connectivity.') 136 | resource cosmosDbPrivateLinkAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 137 | name: guid(resourceGroup().id, cosmosDbPrivateLinkPolicy.id) 138 | scope: resourceGroup() 139 | properties: { 140 | displayName: '${baseName} - ${cosmosDbPrivateLinkPolicy.properties.displayName}' 141 | description: cosmosDbPrivateLinkPolicy.properties.description 142 | policyDefinitionId: cosmosDbPrivateLinkPolicy.id 143 | enforcementMode: 'Default' 144 | parameters: { 145 | effect: { 146 | value: 'Audit' 147 | } 148 | } 149 | } 150 | } 151 | 152 | @description('Policy assignment to audit Cosmos DB accounts and ensure local authentication methods are disabled for improved security.') 153 | resource cosmosDbDisableLocalAuthAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 154 | name: guid(resourceGroup().id, cosmosDbDisableLocalAuthPolicy.id) 155 | scope: resourceGroup() 156 | properties: { 157 | displayName: '${baseName} - ${cosmosDbDisableLocalAuthPolicy.properties.displayName}' 158 | description: cosmosDbDisableLocalAuthPolicy.properties.description 159 | policyDefinitionId: cosmosDbDisableLocalAuthPolicy.id 160 | enforcementMode: 'Default' 161 | parameters: { 162 | effect: { 163 | value: 'Audit' 164 | } 165 | } 166 | } 167 | } 168 | 169 | @description('Policy assignment to audit Cosmos DB accounts and ensure public network access is disabled to enhance security.') 170 | resource cosmosDbDisablePublicNetworkAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 171 | name: guid(resourceGroup().id, cosmosDbDisablePublicNetworkPolicy.id) 172 | scope: resourceGroup() 173 | properties: { 174 | displayName: '${baseName} - ${cosmosDbDisablePublicNetworkPolicy.properties.displayName}' 175 | description: cosmosDbDisablePublicNetworkPolicy.properties.description 176 | policyDefinitionId: cosmosDbDisablePublicNetworkPolicy.id 177 | enforcementMode: 'Default' 178 | parameters: { 179 | effect: { 180 | value: 'Audit' 181 | } 182 | } 183 | } 184 | } 185 | 186 | @description('Policy assignment to audit Azure AI Search services and ensure public network access is disabled for enhanced security.') 187 | resource searchDisablePublicNetworkAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 188 | name: guid(resourceGroup().id, searchDisablePublicNetworkPolicy.id) 189 | scope: resourceGroup() 190 | properties: { 191 | displayName: '${baseName} - ${searchDisablePublicNetworkPolicy.properties.displayName}' 192 | description: searchDisablePublicNetworkPolicy.properties.description 193 | policyDefinitionId: searchDisablePublicNetworkPolicy.id 194 | enforcementMode: 'Default' 195 | parameters: { 196 | effect: { 197 | value: 'Audit' 198 | } 199 | } 200 | } 201 | } 202 | 203 | @description('Policy assignment to audit Azure AI Search services and ensure zone redundancy is configured for high availability.') 204 | resource searchZoneRedundantAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 205 | name: guid(resourceGroup().id, searchZoneRedundantPolicy.id) 206 | scope: resourceGroup() 207 | properties: { 208 | displayName: '${baseName} - ${searchZoneRedundantPolicy.properties.displayName}' 209 | description: searchZoneRedundantPolicy.properties.description 210 | policyDefinitionId: searchZoneRedundantPolicy.id 211 | enforcementMode: 'Default' 212 | parameters: { 213 | effect: { 214 | value: 'Audit' 215 | } 216 | } 217 | } 218 | } 219 | 220 | @description('Policy assignment to audit Azure AI Search services and ensure local authentication methods are disabled for improved security.') 221 | resource searchDisableLocalAuthAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 222 | name: guid(resourceGroup().id, searchDisableLocalAuthPolicy.id) 223 | scope: resourceGroup() 224 | properties: { 225 | displayName: '${baseName} - ${searchDisableLocalAuthPolicy.properties.displayName}' 226 | description: searchDisableLocalAuthPolicy.properties.description 227 | policyDefinitionId: searchDisableLocalAuthPolicy.id 228 | enforcementMode: 'Default' 229 | parameters: { 230 | effect: { 231 | value: 'Audit' 232 | } 233 | } 234 | } 235 | } 236 | 237 | @description('Policy assignment to audit Storage accounts and ensure public network access is disabled for enhanced security.') 238 | resource storageDisablePublicNetworkAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 239 | name: guid(resourceGroup().id, storageDisablePublicNetworkPolicy.id) 240 | scope: resourceGroup() 241 | properties: { 242 | displayName: '${baseName} - ${storageDisablePublicNetworkPolicy.properties.displayName}' 243 | description: storageDisablePublicNetworkPolicy.properties.description 244 | policyDefinitionId: storageDisablePublicNetworkPolicy.id 245 | enforcementMode: 'Default' 246 | parameters: { 247 | effect: { 248 | value: 'Audit' 249 | } 250 | } 251 | } 252 | } 253 | 254 | @description('Policy assignment to audit Storage accounts and ensure shared key access is prevented for improved security posture.') 255 | resource storageDisableSharedKeyAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { 256 | name: guid(resourceGroup().id, storageDisableSharedKeyPolicy.id) 257 | scope: resourceGroup() 258 | properties: { 259 | displayName: '${baseName} - ${storageDisableSharedKeyPolicy.properties.displayName}' 260 | description: storageDisableSharedKeyPolicy.properties.description 261 | policyDefinitionId: storageDisableSharedKeyPolicy.id 262 | enforcementMode: 'Default' 263 | parameters: { 264 | effect: { 265 | value: 'Audit' 266 | } 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /infra-as-code/bicep/bing-grounding.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('This is the base name for each Azure resource name (6-8 chars)') 4 | @minLength(6) 5 | @maxLength(8) 6 | param baseName string 7 | 8 | // ---- New resources ---- 9 | 10 | #disable-next-line BCP081 11 | resource bingAccount 'Microsoft.Bing/accounts@2025-05-01-preview' = { 12 | name: 'bing-ai-agent-${baseName}' 13 | location: 'global' 14 | kind: 'Bing.Grounding' 15 | sku: { 16 | name: 'G1' 17 | } 18 | } 19 | 20 | // ---- Outputs ---- 21 | 22 | output bingAccountName string = bingAccount.name 23 | -------------------------------------------------------------------------------- /infra-as-code/bicep/cosmos-db.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The name of the workload\'s existing Log Analytics workspace.') 13 | @minLength(4) 14 | param logAnalyticsWorkspaceName string 15 | 16 | @description('Assign your user some roles to support access to the Azure AI Agent dependencies for troubleshooting post deployment') 17 | @maxLength(36) 18 | @minLength(36) 19 | param debugUserPrincipalId string 20 | 21 | @description('The resource ID for the subnet that private endpoints in the workload should surface in.') 22 | @minLength(1) 23 | param privateEndpointSubnetResourceId string 24 | 25 | // ---- Existing resources ---- 26 | 27 | resource cosmosDbLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 28 | name: 'privatelink.documents.azure.com' 29 | } 30 | 31 | // Cosmos DB Account Reader Role 32 | resource cosmosDbAccountReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 33 | name: 'fbdf93bf-df7d-467e-a4d2-9458aa1360c8' 34 | scope: subscription() 35 | } 36 | 37 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 38 | name: logAnalyticsWorkspaceName 39 | } 40 | 41 | // ---- New resources ---- 42 | 43 | @description('Deploy an Azure Cosmos DB account. This is a BYO dependency for the Azure AI Agent service. It\'s used to store threads and agent definitions.') 44 | resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' = { 45 | name: 'cdb-ai-agent-threads-${baseName}' 46 | location: location 47 | kind: 'GlobalDocumentDB' 48 | properties: { 49 | consistencyPolicy: { 50 | defaultConsistencyLevel: 'Session' 51 | } 52 | disableLocalAuth: true 53 | enableAutomaticFailover: false 54 | enableMultipleWriteLocations: false 55 | minimalTlsVersion: 'Tls12' 56 | publicNetworkAccess: 'Disabled' 57 | enableFreeTier: false 58 | ipRules: [] 59 | virtualNetworkRules: [] 60 | networkAclBypass: 'None' 61 | networkAclBypassResourceIds: [] 62 | diagnosticLogSettings: { 63 | enableFullTextQuery: 'False' 64 | } 65 | enableBurstCapacity: false 66 | locations: [ 67 | { 68 | locationName: location 69 | failoverPriority: 0 70 | isZoneRedundant: true // Some subscriptions do not have quota to support zone redundancy. If you encounter an error, set this to false. 71 | } 72 | ] 73 | databaseAccountOfferType: 'Standard' 74 | backupPolicy: { 75 | type: 'Continuous' 76 | continuousModeProperties: { 77 | tier: 'Continuous7Days' // You have seven days of continuous backup to address point-in-time restore needs. 78 | } 79 | } 80 | } 81 | 82 | @description('Built-in Cosmos DB Data Contributor role that can be assigned to Entra identities to grant data access on a Cosmos DB database.') 83 | resource dataContributorRole 'sqlRoleDefinitions' existing = { 84 | name: '00000000-0000-0000-0000-000000000002' 85 | } 86 | 87 | @description('Assign your own user to access the enterprise_memory database contents for troubleshooting purposes. Not required for normal usage.') 88 | resource userToCosmos 'sqlRoleAssignments' = { 89 | name: guid(debugUserPrincipalId, dataContributorRole.id, cosmosDbAccount.id) 90 | properties: { 91 | roleDefinitionId: cosmosDbAccount::dataContributorRole.id 92 | principalId: debugUserPrincipalId 93 | scope: cosmosDbAccount.id 94 | } 95 | dependsOn: [ 96 | assignDebugUserToCosmosAccountReader 97 | ] 98 | } 99 | } 100 | 101 | @description('Capture platform logs for the Cosmos DB account.') 102 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 103 | name: 'default' 104 | scope: cosmosDbAccount 105 | properties: { 106 | workspaceId: logAnalyticsWorkspace.id 107 | logs: [ 108 | { 109 | category: 'DataPlaneRequests' 110 | enabled: true 111 | retentionPolicy: { 112 | enabled: false 113 | days: 0 114 | } 115 | } 116 | { 117 | category: 'PartitionKeyRUConsumption' 118 | enabled: true 119 | retentionPolicy: { 120 | enabled: false 121 | days: 0 122 | } 123 | } 124 | { 125 | category: 'ControlPlaneRequests' 126 | enabled: true 127 | retentionPolicy: { 128 | enabled: false 129 | days: 0 130 | } 131 | } 132 | { 133 | category: 'DataPlaneRequests5M' 134 | enabled: true 135 | retentionPolicy: { 136 | enabled: false 137 | days: 0 138 | } 139 | } 140 | { 141 | category: 'DataPlaneRequests15M' 142 | enabled: true 143 | retentionPolicy: { 144 | enabled: false 145 | days: 0 146 | } 147 | } 148 | ] 149 | } 150 | } 151 | 152 | // Private endpoints 153 | 154 | resource cosmosDbPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 155 | name: 'pe-ai-agent-threads' 156 | location: resourceGroup().location 157 | properties: { 158 | subnet: { 159 | id: privateEndpointSubnetResourceId 160 | } 161 | customNetworkInterfaceName: 'nic-ai-agent-threads' 162 | privateLinkServiceConnections: [ 163 | { 164 | name: 'ai-agent-cosmosdb' 165 | properties: { 166 | privateLinkServiceId: cosmosDbAccount.id 167 | groupIds: [ 168 | 'Sql' 169 | ] 170 | } 171 | } 172 | ] 173 | } 174 | 175 | resource dnsGroup 'privateDnsZoneGroups' = { 176 | name: 'ai-agent-cosmosdb' 177 | properties: { 178 | privateDnsZoneConfigs: [ 179 | { 180 | name: 'ai-agent-cosmosdb' 181 | properties: { 182 | privateDnsZoneId: cosmosDbLinkedPrivateDnsZone.id 183 | } 184 | } 185 | ] 186 | } 187 | } 188 | } 189 | 190 | 191 | // Role assignments 192 | 193 | resource assignDebugUserToCosmosAccountReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 194 | name: guid(debugUserPrincipalId, cosmosDbAccountReaderRole.id, cosmosDbAccount.id) 195 | scope: cosmosDbAccount 196 | properties: { 197 | roleDefinitionId: cosmosDbAccountReaderRole.id 198 | principalId: debugUserPrincipalId 199 | principalType: 'User' 200 | } 201 | } 202 | 203 | // ---- Outputs ---- 204 | 205 | output cosmosDbAccountName string = cosmosDbAccount.name 206 | -------------------------------------------------------------------------------- /infra-as-code/bicep/customerUsageAttribution/cuaIdResourceGroup.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | // This is an empty deployment by design 4 | // Reference: https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution 5 | -------------------------------------------------------------------------------- /infra-as-code/bicep/jump-box.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('The name of the virtual network in this resource group.') 8 | @minLength(1) 9 | param virtualNetworkName string 10 | 11 | @description('The name of the subnet for the jump box. Must be in the same virtual network that is provided.') 12 | @minLength(1) 13 | param jumpBoxSubnetName string 14 | 15 | @description('The name of the workload\'s existing Log Analytics workspace.') 16 | @minLength(4) 17 | param logAnalyticsWorkspaceName string 18 | 19 | @description('Specifies the name of the administrator account on the Windows jump box. Cannot end in "."\n\nDisallowed values: "administrator", "admin", "user", "user1", "test", "user2", "test1", "user3", "admin1", "1", "123", "a", "actuser", "adm", "admin2", "aspnet", "backup", "console", "david", "guest", "john", "owner", "root", "server", "sql", "support", "support_388945a0", "sys", "test2", "test3", "user4", "user5".\n\nDefault: vmadmin') 20 | @minLength(4) 21 | @maxLength(20) 22 | param jumpBoxAdminName string = 'vmadmin' 23 | 24 | @description('Specifies the password of the administrator account on the Windows jump box.\n\nComplexity requirements: 3 out of 4 conditions below need to be fulfilled:\n- Has lower characters\n- Has upper characters\n- Has a digit\n- Has a special character\n\nDisallowed values: "abc@123", "P@$$w0rd", "P@ssw0rd", "P@ssword123", "Pa$$word", "pass@word1", "Password!", "Password1", "Password22", "iloveyou!"') 25 | @secure() 26 | @minLength(8) 27 | @maxLength(123) 28 | param jumpBoxAdminPassword string 29 | 30 | // ---- Variables ---- 31 | 32 | var bastionHostName = 'ab-jump-box' 33 | var jumpBoxName = 'jump-box' 34 | 35 | // ---- Existing resources ---- 36 | 37 | @description('Existing virtual network for the solution.') 38 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 39 | name: virtualNetworkName 40 | 41 | resource jumpBoxSubnet 'subnets' existing = { 42 | name: jumpBoxSubnetName 43 | } 44 | 45 | resource bastionSubnet 'subnets' existing = { 46 | name: 'AzureBastionSubnet' 47 | } 48 | } 49 | 50 | @description('Existing Log Analyitics workspace, used as the common log sink for the workload.') 51 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 52 | name: logAnalyticsWorkspaceName 53 | } 54 | 55 | // New resources 56 | 57 | @description('Required public IP for the Azure Bastion service, used for jump box access.') 58 | resource bastionPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { 59 | name: 'pip-${bastionHostName}' 60 | location: location 61 | zones: pickZones('Microsoft.Network', 'publicIPAddresses', location, 3) 62 | sku: { 63 | name: 'Standard' 64 | tier: 'Regional' 65 | } 66 | properties: { 67 | ddosSettings: { 68 | ddosProtectionPlan: null 69 | protectionMode: 'VirtualNetworkInherited' 70 | } 71 | deleteOption: 'Delete' 72 | dnsSettings: { 73 | domainNameLabel: bastionHostName 74 | } 75 | publicIPAddressVersion: 'IPv4' 76 | publicIPAllocationMethod: 'Static' 77 | } 78 | } 79 | 80 | @description('Deploys Azure Bastion for secure access to the jump box.') 81 | resource bastion 'Microsoft.Network/bastionHosts@2024-05-01' = { 82 | name: bastionHostName 83 | location: location 84 | sku: { 85 | name: 'Basic' 86 | } 87 | properties: { 88 | disableCopyPaste: false 89 | enableFileCopy: false 90 | enableIpConnect: false 91 | enableKerberos: false 92 | enableShareableLink: false 93 | enableTunneling: false 94 | enableSessionRecording: false 95 | scaleUnits: 2 96 | ipConfigurations: [ 97 | { 98 | name: 'default' 99 | properties: { 100 | privateIPAllocationMethod: 'Dynamic' 101 | publicIPAddress: { 102 | id: bastionPublicIp.id 103 | } 104 | subnet: { 105 | id: virtualNetwork::bastionSubnet.id 106 | } 107 | } 108 | } 109 | ] 110 | } 111 | } 112 | 113 | @description('Diagnostics settings for Azure Bastion') 114 | resource azureDiagnosticsBastion 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 115 | name: 'default' 116 | scope: bastion 117 | properties: { 118 | workspaceId: logWorkspace.id 119 | logs: [ 120 | { 121 | category: 'BastionAuditLogs' 122 | enabled: true 123 | retentionPolicy: { 124 | enabled: false 125 | days: 0 126 | } 127 | } 128 | ] 129 | } 130 | } 131 | 132 | @description('Default VM Insights DCR rule, to be applied to the jump box.') 133 | resource virtualMachineInsightsDcr 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { 134 | name: 'dcr-${jumpBoxName}' 135 | location: location 136 | kind: 'Windows' 137 | properties: { 138 | description: 'Standard data collection rule for VM Insights' 139 | dataSources: { 140 | performanceCounters: [ 141 | { 142 | name: 'VMInsightsPerfCounters' 143 | streams: [ 144 | 'Microsoft-InsightsMetrics' 145 | ] 146 | samplingFrequencyInSeconds: 60 147 | counterSpecifiers: [ 148 | '\\VMInsights\\DetailedMetrics' 149 | ] 150 | } 151 | ] 152 | extensions: [ 153 | { 154 | name: 'DependencyAgentDataSource' 155 | extensionName: 'DependencyAgent' 156 | streams: [ 157 | 'Microsoft-ServiceMap' 158 | ] 159 | extensionSettings: {} 160 | } 161 | ] 162 | } 163 | destinations: { 164 | logAnalytics: [ 165 | { 166 | name: logWorkspace.name 167 | workspaceResourceId: logWorkspace.id 168 | } 169 | ] 170 | } 171 | dataFlows: [ 172 | { 173 | streams: [ 174 | 'Microsoft-InsightsMetrics' 175 | 'Microsoft-ServiceMap' 176 | ] 177 | destinations: [ 178 | logWorkspace.name 179 | ] 180 | } 181 | ] 182 | } 183 | } 184 | 185 | @description('Enable Azure Diagnostics for the the jump box\'s DCR.') 186 | resource azureDiagnosticsDcr 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 187 | name: 'default' 188 | scope: virtualMachineInsightsDcr 189 | properties: { 190 | workspaceId: logWorkspace.id 191 | logs: [ 192 | { 193 | category: 'LogErrors' 194 | categoryGroup: null 195 | enabled: true 196 | retentionPolicy: { 197 | days: 0 198 | enabled: false 199 | } 200 | } 201 | ] 202 | } 203 | } 204 | 205 | @description('The jump box virtual machine will only receive a private IP.') 206 | resource jumpBoxPrivateNic 'Microsoft.Network/networkInterfaces@2024-05-01' = { 207 | name: 'nic-${jumpBoxName}' 208 | location: location 209 | properties: { 210 | nicType: 'Standard' 211 | auxiliaryMode: 'None' 212 | auxiliarySku: 'None' 213 | enableIPForwarding: false 214 | enableAcceleratedNetworking: false 215 | ipConfigurations: [ 216 | { 217 | name: 'primary' 218 | properties: { 219 | primary: true 220 | subnet: { 221 | id: virtualNetwork::jumpBoxSubnet.id 222 | } 223 | privateIPAllocationMethod: 'Dynamic' 224 | privateIPAddressVersion: 'IPv4' 225 | publicIPAddress: null 226 | applicationSecurityGroups: [] 227 | } 228 | } 229 | ] 230 | } 231 | } 232 | 233 | @description('The Azure AI Foundry portal is only able to be accessed from the virtual network, this jump box gives you access to that portal.') 234 | resource jumpBoxVirtualMachine 'Microsoft.Compute/virtualMachines@2024-11-01' = { 235 | name: 'vm-${jumpBoxName}' 236 | location: location 237 | zones: pickZones('Microsoft.Compute', 'virtualMachines', location, 1) 238 | identity: { 239 | type: 'SystemAssigned' 240 | } 241 | properties: { 242 | additionalCapabilities: { 243 | hibernationEnabled: false 244 | ultraSSDEnabled: false 245 | } 246 | applicationProfile: null 247 | availabilitySet: null 248 | diagnosticsProfile: { 249 | bootDiagnostics: { 250 | enabled: true 251 | storageUri: null 252 | } 253 | } 254 | hardwareProfile: { 255 | vmSize: 'Standard_D2s_v3' 256 | } 257 | licenseType: 'Windows_Client' 258 | networkProfile: { 259 | networkInterfaces: [ 260 | { 261 | id: jumpBoxPrivateNic.id 262 | } 263 | ] 264 | } 265 | osProfile: { 266 | computerName: 'jumpbox' 267 | adminUsername: jumpBoxAdminName 268 | adminPassword: jumpBoxAdminPassword 269 | allowExtensionOperations: true 270 | windowsConfiguration: { 271 | enableAutomaticUpdates: true 272 | patchSettings: { 273 | patchMode: 'AutomaticByOS' 274 | assessmentMode: 'ImageDefault' 275 | } 276 | provisionVMAgent: true 277 | } 278 | } 279 | priority: 'Regular' 280 | scheduledEventsProfile: { 281 | osImageNotificationProfile: { 282 | enable: true 283 | } 284 | terminateNotificationProfile: { 285 | enable: true 286 | } 287 | } 288 | securityProfile: { 289 | securityType: 'TrustedLaunch' 290 | uefiSettings: { 291 | secureBootEnabled: true 292 | vTpmEnabled: true 293 | } 294 | } 295 | storageProfile: { 296 | dataDisks: [] 297 | diskControllerType: 'SCSI' 298 | osDisk: { 299 | createOption: 'FromImage' 300 | caching: 'ReadOnly' 301 | deleteOption: 'Delete' 302 | diffDiskSettings: null 303 | managedDisk: { 304 | storageAccountType: 'Premium_LRS' 305 | } 306 | encryptionSettings: { 307 | enabled: false 308 | } 309 | osType: 'Windows' 310 | diskSizeGB: 127 311 | } 312 | imageReference: { 313 | offer: 'windows-11' 314 | publisher: 'MicrosoftWindowsDesktop' 315 | sku: 'win11-24h2-pro' 316 | version: 'latest' 317 | } 318 | } 319 | } 320 | 321 | @description('Support remote admin password changes.') 322 | resource vmAccessExtension 'extensions' = { 323 | name: 'enablevmAccess' 324 | location: location 325 | properties: { 326 | autoUpgradeMinorVersion: true 327 | enableAutomaticUpgrade: false 328 | publisher: 'Microsoft.Compute' 329 | type: 'VMAccessAgent' 330 | typeHandlerVersion: '2.0' 331 | settings: {} 332 | } 333 | } 334 | 335 | @description('Install Azure CLI on the jump box.') 336 | resource azureCliExtension 'extensions' = { 337 | name: 'installAzureCLI' 338 | location: location 339 | properties: { 340 | autoUpgradeMinorVersion: true 341 | enableAutomaticUpgrade: false 342 | publisher: 'Microsoft.Compute' 343 | type: 'CustomScriptExtension' 344 | typeHandlerVersion: '1.10' 345 | settings: { 346 | commandToExecute: 'powershell -ExecutionPolicy Unrestricted -Command "Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile .\\AzureCLI.msi; Start-Process msiexec.exe -Wait -ArgumentList \'/I AzureCLI.msi /quiet\'"' 347 | } 348 | } 349 | } 350 | 351 | @description('Enable Azure Monitor Agent for observability though VM Insights.') 352 | resource amaExtension 'extensions' = { 353 | name: 'AzureMonitorWindowsAgent' 354 | location: location 355 | properties: { 356 | autoUpgradeMinorVersion: true 357 | enableAutomaticUpgrade: true 358 | publisher: 'Microsoft.Azure.Monitor' 359 | type: 'AzureMonitorWindowsAgent' 360 | typeHandlerVersion: '1.34' 361 | } 362 | } 363 | 364 | @description('Dependency Agent for service map support in Azure Monitor Agent.') 365 | resource amaDependencyAgent 'extensions' = { 366 | name: 'DependencyAgentWindows' 367 | location: location 368 | properties: { 369 | autoUpgradeMinorVersion: true 370 | enableAutomaticUpgrade: true 371 | publisher: 'Microsoft.Azure.Monitoring.DependencyAgent' 372 | type: 'DependencyAgentWindows' 373 | typeHandlerVersion: '9.10' 374 | settings: { 375 | enableAMA: 'true' 376 | } 377 | } 378 | } 379 | } 380 | 381 | @description('Associate jump box with Azure Monitor Agent VM Insights DCR.') 382 | resource jumpBoxDcrAssociation 'Microsoft.Insights/dataCollectionRuleAssociations@2023-03-11' = { 383 | name: 'dcra-vminsights' 384 | scope: jumpBoxVirtualMachine 385 | properties: { 386 | dataCollectionRuleId: virtualMachineInsightsDcr.id 387 | description: 'VM Insights DCR association with the jump box.' 388 | } 389 | dependsOn: [ 390 | jumpBoxVirtualMachine::amaDependencyAgent 391 | ] 392 | } 393 | -------------------------------------------------------------------------------- /infra-as-code/bicep/key-vault.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | /* 4 | Deploy Key Vault with private endpoint and private DNS zone 5 | */ 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 13 | @minLength(1) 14 | param location string = resourceGroup().location 15 | 16 | @description('The certificate data for app gateway TLS termination. The value is base64 encoded') 17 | @secure() 18 | param appGatewayListenerCertificate string 19 | 20 | @description('The name of the existing virtual network. This Key Vault will expose a private endpoint into this network.') 21 | @minLength(1) 22 | param virtualNetworkName string 23 | 24 | @description('The name for the subnet that private endpoints in the workload should surface in.') 25 | @minLength(1) 26 | param privateEndpointsSubnetName string 27 | 28 | @description('The name of the workload\'s existing Log Analytics workspace.') 29 | @minLength(4) 30 | param logAnalyticsWorkspaceName string 31 | 32 | // ---- Existing resources ---- 33 | 34 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 35 | name: virtualNetworkName 36 | 37 | resource privateEndpointsSubnet 'subnets' existing = { 38 | name: privateEndpointsSubnetName 39 | } 40 | } 41 | 42 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 43 | name: logAnalyticsWorkspaceName 44 | } 45 | 46 | @description('Azure Key Vault private DNS zone') 47 | resource existingKeyVaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 48 | name: 'privatelink.vaultcore.azure.net' // Cannot use 'privatelink.${environment().suffixes.keyvaultDns}', per https://github.com/Azure/bicep/issues/9708 49 | } 50 | 51 | // ---- New resources ---- 52 | 53 | resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = { 54 | name: 'kv-${baseName}' 55 | location: location 56 | properties: { 57 | sku: { 58 | family: 'A' 59 | name: 'standard' 60 | } 61 | networkAcls: { 62 | defaultAction: 'Deny' 63 | bypass: 'AzureServices' // Required for AppGW communication 64 | ipRules: [] 65 | virtualNetworkRules: [] 66 | } 67 | publicNetworkAccess: 'Disabled' 68 | tenantId: subscription().tenantId 69 | enableRbacAuthorization: true // Using RBAC 70 | enabledForDeployment: true // VMs can retrieve certificates 71 | enabledForTemplateDeployment: true // ARM can retrieve values 72 | accessPolicies: [] // Using RBAC 73 | enabledForDiskEncryption: false 74 | enableSoftDelete: true 75 | softDeleteRetentionInDays: 7 76 | createMode: 'default' // Creating or updating the Key Vault (not recovering) 77 | } 78 | 79 | resource kvsGatewayPublicCert 'secrets' = { 80 | name: 'gateway-public-cert' 81 | properties: { 82 | value: appGatewayListenerCertificate 83 | contentType: 'application/x-pkcs12' 84 | attributes: { 85 | enabled: true 86 | } 87 | } 88 | } 89 | } 90 | 91 | @description('Enable Azure Diagnostics for Key Vault') 92 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 93 | name: 'default' 94 | scope: keyVault 95 | properties: { 96 | workspaceId: logWorkspace.id 97 | logs: [ 98 | { 99 | category: 'AuditEvent' 100 | enabled: true 101 | retentionPolicy: { 102 | enabled: false 103 | days: 0 104 | } 105 | } 106 | { 107 | category: 'AzurePolicyEvaluationDetails' 108 | enabled: true 109 | retentionPolicy: { 110 | enabled: false 111 | days: 0 112 | } 113 | } 114 | ] 115 | } 116 | } 117 | 118 | // Private endpoints 119 | 120 | resource keyVaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 121 | name: 'pe-key-vault' 122 | location: location 123 | properties: { 124 | subnet: { 125 | id: virtualNetwork::privateEndpointsSubnet.id 126 | } 127 | customNetworkInterfaceName: 'nic-${keyVault.name}' 128 | privateLinkServiceConnections: [ 129 | { 130 | name: 'key-vault' 131 | properties: { 132 | privateLinkServiceId: keyVault.id 133 | groupIds: [ 134 | 'vault' 135 | ] 136 | } 137 | } 138 | ] 139 | } 140 | 141 | resource keyVaultDnsZoneGroup 'privateDnsZoneGroups' = { 142 | name: 'key-vault' 143 | properties: { 144 | privateDnsZoneConfigs: [ 145 | { 146 | name: 'key-vault' 147 | properties: { 148 | privateDnsZoneId: existingKeyVaultPrivateDnsZone.id 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | } 155 | 156 | // ---- Outputs ---- 157 | 158 | @description('The name of the Key Vault.') 159 | output keyVaultName string = keyVault.name 160 | 161 | @description('Name of the secret holding the cert.') 162 | output gatewayCertSecretKey string = keyVault::kvsGatewayPublicCert.name 163 | -------------------------------------------------------------------------------- /infra-as-code/bicep/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 4 | @minLength(1) 5 | param location string = resourceGroup().location 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('Domain name to use for App Gateway') 13 | @minLength(3) 14 | param customDomainName string = 'contoso.com' 15 | 16 | @description('The certificate data for app gateway TLS termination. The value is base64 encoded.') 17 | @secure() 18 | @minLength(1) 19 | param appGatewayListenerCertificate string 20 | 21 | @description('The name of the web deploy file. The file should reside in a deploy container in the Azure Storage account. Defaults to chatui.zip') 22 | @minLength(5) 23 | param publishFileName string = 'chatui.zip' 24 | 25 | @description('Specifies the password of the administrator account on the Windows jump box.\n\nComplexity requirements: 3 out of 4 conditions below need to be fulfilled:\n- Has lower characters\n- Has upper characters\n- Has a digit\n- Has a special character\n\nDisallowed values: "abc@123", "P@$$w0rd", "P@ssw0rd", "P@ssword123", "Pa$$word", "pass@word1", "Password!", "Password1", "Password22", "iloveyou!"') 26 | @secure() 27 | @minLength(8) 28 | @maxLength(123) 29 | param jumpBoxAdminPassword string 30 | 31 | @description('Assign your user some roles to support fluid access when working in the Azure AI Foundry portal and its dependencies.') 32 | @maxLength(36) 33 | @minLength(36) 34 | param yourPrincipalId string 35 | 36 | @description('Set to true to opt-out of deployment telemetry.') 37 | param telemetryOptOut bool = false 38 | 39 | // Customer Usage Attribution Id 40 | var varCuaid = 'a52aa8a8-44a8-46e9-b7a5-189ab3a64409' 41 | 42 | // ---- New resources ---- 43 | 44 | @description('Deploy an example set of Azure Policies to help you govern your workload. Expand the policy set as desired.') 45 | module applyAzurePolicies 'azure-policies.bicep' = { 46 | scope: resourceGroup() 47 | params: { 48 | baseName: baseName 49 | } 50 | } 51 | 52 | @description('This is the log sink for all Azure Diagnostics in the workload.') 53 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { 54 | name: 'log-workload' 55 | location: location 56 | properties: { 57 | sku: { 58 | name: 'PerGB2018' 59 | } 60 | retentionInDays: 30 61 | forceCmkForQuery: false 62 | workspaceCapping: { 63 | dailyQuotaGb: 10 // Production readiness change: In production, tune this value to ensure operational logs are collected, but a reasonable cap is set. 64 | } 65 | publicNetworkAccessForIngestion: 'Enabled' 66 | publicNetworkAccessForQuery: 'Enabled' 67 | } 68 | } 69 | 70 | @description('Deploy Virtual Network, with subnets, NSGs, and DDoS Protection.') 71 | module deployVirtualNetwork 'network.bicep' = { 72 | scope: resourceGroup() 73 | params: { 74 | location: location 75 | } 76 | } 77 | 78 | @description('Control egress traffic through Azure Firewall restrictions.') 79 | module deployAzureFirewall 'azure-firewall.bicep' = { 80 | scope: resourceGroup() 81 | params: { 82 | location: location 83 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 84 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 85 | agentsEgressSubnetName: deployVirtualNetwork.outputs.agentsEgressSubnetName 86 | jumpBoxesSubnetName: deployVirtualNetwork.outputs.jumpBoxesSubnetName 87 | } 88 | } 89 | 90 | @description('Deploys Azure Bastion and the jump box, which is used for private access to Azure AI Foundry and its dependencies.') 91 | module deployJumpBox 'jump-box.bicep' = { 92 | scope: resourceGroup() 93 | params: { 94 | location: location 95 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 96 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 97 | jumpBoxSubnetName: deployVirtualNetwork.outputs.jumpBoxSubnetName 98 | jumpBoxAdminName: 'vmadmin' 99 | jumpBoxAdminPassword: jumpBoxAdminPassword 100 | } 101 | dependsOn: [ 102 | deployAzureFirewall // Makes sure that egress traffic is controlled before workload resources start being deployed 103 | ] 104 | } 105 | 106 | // Deploy the Azure AI Foundry account and Azure AI Agent service components. 107 | 108 | @description('Deploy Azure AI Foundry with Azure AI Agent capability. No projects yet deployed.') 109 | module deployAzureAIFoundry 'ai-foundry.bicep' = { 110 | scope: resourceGroup() 111 | params: { 112 | location: location 113 | baseName: baseName 114 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 115 | agentSubnetResourceId: deployVirtualNetwork.outputs.agentsEgressSubnetResourceId 116 | privateEndpointSubnetResourceId: deployVirtualNetwork.outputs.privateEndpointsSubnetResourceId 117 | aiFoundryPortalUserPrincipalId: yourPrincipalId 118 | } 119 | dependsOn: [ 120 | deployAzureFirewall // Makes sure that egress traffic is controlled before workload resources start being deployed 121 | ] 122 | } 123 | 124 | @description('Deploys the Azure AI Agent dependencies, Azure Storage, Azure AI Search, and Cosmos DB.') 125 | module deployAIAgentServiceDependencies 'ai-agent-service-dependencies.bicep' = { 126 | scope: resourceGroup() 127 | params: { 128 | location: location 129 | baseName: baseName 130 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 131 | debugUserPrincipalId: yourPrincipalId 132 | privateEndpointSubnetResourceId: deployVirtualNetwork.outputs.privateEndpointsSubnetResourceId 133 | } 134 | } 135 | 136 | @description('Deploy the Bing account for Internet grounding data to be used by agents in the Azure AI Agent service.') 137 | module deployBingAccount 'bing-grounding.bicep' = { 138 | scope: resourceGroup() 139 | params: { 140 | baseName: baseName 141 | } 142 | } 143 | 144 | @description('Deploy the Azure AI Foundry project into the AI Foundry account. This is the project is the home of the Azure AI Agent service.') 145 | module deployAzureAiFoundryProject 'ai-foundry-project.bicep' = { 146 | scope: resourceGroup() 147 | params: { 148 | location: location 149 | existingAiFoundryName: deployAzureAIFoundry.outputs.aiFoundryName 150 | existingAISearchAccountName: deployAIAgentServiceDependencies.outputs.aiSearchName 151 | existingCosmosDbAccountName: deployAIAgentServiceDependencies.outputs.cosmosDbAccountName 152 | existingStorageAccountName: deployAIAgentServiceDependencies.outputs.storageAccountName 153 | existingBingAccountName: deployBingAccount.outputs.bingAccountName 154 | existingWebApplicationInsightsResourceName: deployApplicationInsights.outputs.applicationInsightsName 155 | } 156 | dependsOn: [ 157 | deployJumpBox 158 | ] 159 | } 160 | 161 | // Deploy the Azure Web App resources for the chat UI. 162 | 163 | @description('Deploy an Azure Storage account that is used by the Azure Web App for the deployed application code.') 164 | module deployWebAppStorage 'web-app-storage.bicep' = { 165 | scope: resourceGroup() 166 | params: { 167 | location: location 168 | baseName: baseName 169 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 170 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 171 | privateEndpointsSubnetName: deployVirtualNetwork.outputs.privateEndpointsSubnetName 172 | debugUserPrincipalId: yourPrincipalId 173 | } 174 | dependsOn: [ 175 | deployAIAgentServiceDependencies // There is a Storage account in the AI Agent dependencies module, both will be updating the same private DNS zone, want to run them in series to avoid conflict errors. 176 | ] 177 | } 178 | 179 | @description('Deploy Azure Key Vault. In this architecture, it\'s used to store the certificate for the Application Gateway.') 180 | module deployKeyVault 'key-vault.bicep' = { 181 | scope: resourceGroup() 182 | params: { 183 | location: location 184 | baseName: baseName 185 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 186 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 187 | privateEndpointsSubnetName: deployVirtualNetwork.outputs.privateEndpointsSubnetName 188 | appGatewayListenerCertificate: appGatewayListenerCertificate 189 | } 190 | } 191 | 192 | @description('Deploy Application Insights. Used by the Azure Web App to monitor the deployed application and connected to the Azure AI Foundry project.') 193 | module deployApplicationInsights 'application-insights.bicep' = { 194 | scope: resourceGroup() 195 | params: { 196 | location: location 197 | baseName: baseName 198 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 199 | } 200 | } 201 | 202 | @description('Deploy the web app for the front end demo UI. The web application will call into the Azure AI Agent service.') 203 | module deployWebApp 'web-app.bicep' = { 204 | scope: resourceGroup() 205 | params: { 206 | location: location 207 | baseName: baseName 208 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 209 | publishFileName: publishFileName 210 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 211 | appServicesSubnetName: deployVirtualNetwork.outputs.appServicesSubnetName 212 | privateEndpointsSubnetName: deployVirtualNetwork.outputs.privateEndpointsSubnetName 213 | existingWebAppDeploymentStorageAccountName: deployWebAppStorage.outputs.appDeployStorageName 214 | existingWebApplicationInsightsResourceName: deployApplicationInsights.outputs.applicationInsightsName 215 | existingAzureAiFoundryResourceName: deployAzureAIFoundry.outputs.aiFoundryName 216 | existingAzureAiFoundryProjectName: deployAzureAiFoundryProject.outputs.aiAgentProjectName 217 | } 218 | } 219 | 220 | @description('Deploy an Azure Application Gateway with WAF and a custom domain name + TLS cert.') 221 | module deployApplicationGateway 'application-gateway.bicep' = { 222 | scope: resourceGroup() 223 | params: { 224 | location: location 225 | baseName: baseName 226 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.name 227 | customDomainName: customDomainName 228 | appName: deployWebApp.outputs.appName 229 | virtualNetworkName: deployVirtualNetwork.outputs.virtualNetworkName 230 | applicationGatewaySubnetName: deployVirtualNetwork.outputs.applicationGatewaySubnetName 231 | keyVaultName: deployKeyVault.outputs.keyVaultName 232 | gatewayCertSecretKey: deployKeyVault.outputs.gatewayCertSecretKey 233 | } 234 | } 235 | 236 | // Optional Deployment for Customer Usage Attribution 237 | module customerUsageAttributionModule 'customerUsageAttribution/cuaIdResourceGroup.bicep' = if (!telemetryOptOut) { 238 | #disable-next-line no-loc-expr-outside-params // Only to ensure telemetry data is stored in same location as deployment. See https://github.com/Azure/ALZ-Bicep/wiki/FAQ#why-are-some-linter-rules-disabled-via-the-disable-next-line-bicep-function for more information 239 | name: 'pid-${varCuaid}-${uniqueString(resourceGroup().location)}' 240 | scope: resourceGroup() 241 | params: {} 242 | } 243 | -------------------------------------------------------------------------------- /infra-as-code/bicep/modules/keyvaultRoleAssignment.bicep: -------------------------------------------------------------------------------- 1 | /* 2 | This template creates a role assignment for a managed identity to access secrets in Key Vault. 3 | 4 | To ensure that each deployment has a unique role assignment ID, you can use the guid() function with a seed value that is based in part on the 5 | managed identity's principal ID. However, because Azure Resource Manager requires each resource's name to be available at the beginning of the deployment, 6 | you can't use this approach in the same Bicep file that defines the managed identity. This sample uses a Bicep module to work around this issue. 7 | */ 8 | @description('The Id of the role definition.') 9 | param roleDefinitionId string 10 | 11 | @description('The principalId property of the managed identity.') 12 | param principalId string 13 | 14 | @description('The name of the Key Vault resource.') 15 | param keyVaultName string 16 | 17 | // ---- Existing resources ---- 18 | resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { 19 | name: keyVaultName 20 | } 21 | 22 | // ---- Role assignment ---- 23 | resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 24 | name: guid(resourceGroup().id, principalId, roleDefinitionId) 25 | scope: keyVault 26 | properties: { 27 | roleDefinitionId: roleDefinitionId 28 | principalId: principalId 29 | principalType: 'ServicePrincipal' 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /infra-as-code/bicep/network.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | /* 4 | Establish the private network for the workload. 5 | */ 6 | 7 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 8 | @minLength(1) 9 | param location string = resourceGroup().location 10 | 11 | // Azure AI Agent service currently has a limitation on subnet prefixes. 12 | // 10.x was not supported, as such 192.168.x.x was used. 13 | var virtualNetworkAddressPrefix = '192.168.0.0/16' 14 | var appGatewaySubnetPrefix = '192.168.1.0/24' 15 | var appServicesSubnetPrefix = '192.168.0.0/24' 16 | var privateEndpointsSubnetPrefix = '192.168.2.0/27' 17 | var buildAgentsSubnetPrefix = '192.168.2.32/27' 18 | var bastionSubnetPrefix = '192.168.2.64/26' 19 | var jumpBoxSubnetPrefix = '192.168.2.128/28' 20 | var aiAgentsEgressSubnetPrefix = '192.168.3.0/24' 21 | var azureFirewallSubnetPrefix = '192.168.4.0/26' 22 | var azureFirewallManagementSubnetPrefix = '192.168.4.64/26' 23 | 24 | var enableDdosProtection = false // Production readiness change: protect your public IPs in this architecture with DDoS protection by setting this to true. 25 | 26 | // ---- New resources ---- 27 | 28 | // DDoS Protection Plan 29 | // Cost optimization: DDoS protection plans are relatively expensive. If deploying this as part of 30 | // a POC and your environment can be down during a targeted DDoS attack, consider not deploying 31 | // this resource by setting `enableDdosProtection` to false. 32 | resource ddosProtectionPlan 'Microsoft.Network/ddosProtectionPlans@2024-01-01' = if (enableDdosProtection) { 33 | name: 'ddos-workload' 34 | location: location 35 | properties: {} 36 | } 37 | 38 | @description('Virtual Network for the workload. Contains subnets for App Gateway, App Service Plan, Private Endpoints, Build Agents, Bastion Host, Jump Box, and Azure AI Agents service.') 39 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { 40 | name: 'vnet-workload' 41 | location: location 42 | properties: { 43 | enableDdosProtection: enableDdosProtection 44 | ddosProtectionPlan: enableDdosProtection ? { id: ddosProtectionPlan.id } : null 45 | encryption: { 46 | enabled: false 47 | enforcement: 'AllowUnencrypted' 48 | } 49 | addressSpace: { 50 | addressPrefixes: [ 51 | virtualNetworkAddressPrefix 52 | ] 53 | } 54 | subnets: [ 55 | { 56 | // App services plan subnet 57 | name: 'snet-appServicePlan' 58 | properties: { 59 | addressPrefix: appServicesSubnetPrefix 60 | delegations: [ 61 | { 62 | name: 'delegation' 63 | properties: { 64 | serviceName: 'Microsoft.Web/serverFarms' 65 | } 66 | } 67 | ] 68 | networkSecurityGroup: { 69 | id: appServiceSubnetNsg.id 70 | } 71 | } 72 | } 73 | { 74 | // App Gateway subnet 75 | name: 'snet-appGateway' 76 | properties: { 77 | addressPrefix: appGatewaySubnetPrefix 78 | delegations: [] 79 | networkSecurityGroup: { 80 | id: appGatewaySubnetNsg.id 81 | } 82 | privateEndpointNetworkPolicies: 'Disabled' 83 | privateLinkServiceNetworkPolicies: 'Enabled' 84 | } 85 | } 86 | { 87 | // Private endpoints subnet 88 | name: 'snet-privateEndpoints' 89 | properties: { 90 | addressPrefix: privateEndpointsSubnetPrefix 91 | delegations: [] 92 | networkSecurityGroup: { 93 | id: privateEndpointsSubnetNsg.id 94 | } 95 | privateEndpointNetworkPolicies: 'Enabled' // Route Table and NSGs 96 | privateLinkServiceNetworkPolicies: 'Enabled' 97 | defaultOutboundAccess: false // This subnet should never be the source of egress traffic. 98 | routeTable: { 99 | id: egressRouteTable.id 100 | } 101 | } 102 | } 103 | { 104 | // Build agents subnet 105 | name: 'snet-buildAgents' 106 | properties: { 107 | addressPrefix: buildAgentsSubnetPrefix 108 | delegations: [] 109 | networkSecurityGroup: { 110 | id: buildAgentsSubnetNsg.id 111 | } 112 | privateEndpointNetworkPolicies: 'Disabled' 113 | privateLinkServiceNetworkPolicies: 'Enabled' 114 | defaultOutboundAccess: false // Force your build agent traffic through your firewall. 115 | routeTable: { 116 | id: egressRouteTable.id 117 | } 118 | } 119 | } 120 | { 121 | // Azure Bastion subnet 122 | name: 'AzureBastionSubnet' 123 | properties: { 124 | addressPrefix: bastionSubnetPrefix 125 | delegations: [] 126 | networkSecurityGroup: { 127 | id: bastionSubnetNsg.id 128 | } 129 | privateEndpointNetworkPolicies: 'Disabled' 130 | privateLinkServiceNetworkPolicies: 'Enabled' 131 | defaultOutboundAccess: false 132 | } 133 | } 134 | { 135 | // Jump box virtual machine subnet 136 | name: 'snet-jumpBoxes' 137 | properties: { 138 | addressPrefix: jumpBoxSubnetPrefix 139 | delegations: [] 140 | networkSecurityGroup: { 141 | id: jumpBoxSubnetNsg.id 142 | } 143 | privateEndpointNetworkPolicies: 'Disabled' 144 | privateLinkServiceNetworkPolicies: 'Enabled' 145 | defaultOutboundAccess: false // Force agent traffic through your firewall. 146 | routeTable: { 147 | id: egressRouteTable.id 148 | } 149 | } 150 | } 151 | { 152 | // Azure AI Agent service subnet for egress traffic 153 | name: 'snet-agentsEgress' 154 | properties: { 155 | addressPrefix: aiAgentsEgressSubnetPrefix 156 | delegations: [ 157 | { 158 | name: 'Microsoft.App/environments' 159 | properties: { 160 | serviceName: 'Microsoft.App/environments' 161 | } 162 | } 163 | ] 164 | networkSecurityGroup: { 165 | id: azureAiAgentServiceSubnetNsg.id 166 | } 167 | privateEndpointNetworkPolicies: 'Disabled' 168 | privateLinkServiceNetworkPolicies: 'Enabled' 169 | defaultOutboundAccess: false // Force agent traffic through your firewall. 170 | routeTable: { 171 | id: egressRouteTable.id 172 | } 173 | } 174 | } 175 | { 176 | // Workload firewall for all egress traffic 177 | name: 'AzureFirewallSubnet' 178 | properties: { 179 | addressPrefix: azureFirewallSubnetPrefix 180 | delegations: [] 181 | privateEndpointNetworkPolicies: 'Disabled' 182 | privateLinkServiceNetworkPolicies: 'Enabled' 183 | } 184 | } 185 | { 186 | // Workload firewall for all egress traffic 187 | name: 'AzureFirewallManagementSubnet' 188 | properties: { 189 | addressPrefix: azureFirewallManagementSubnetPrefix 190 | delegations: [] 191 | privateEndpointNetworkPolicies: 'Disabled' 192 | privateLinkServiceNetworkPolicies: 'Enabled' 193 | } 194 | } 195 | ] 196 | } 197 | 198 | resource appGatewaySubnet 'subnets' existing = { 199 | name: 'snet-appGateway' 200 | } 201 | 202 | resource appServiceSubnet 'subnets' existing = { 203 | name: 'snet-appServicePlan' 204 | } 205 | 206 | resource privateEndpointsSubnet 'subnets' existing = { 207 | name: 'snet-privateEndpoints' 208 | } 209 | 210 | resource buildAgentsSubnet 'subnets' existing = { 211 | name: 'snet-buildAgents' 212 | } 213 | 214 | resource jumpBoxSubnet 'subnets' existing = { 215 | name: 'snet-jumpBoxes' 216 | } 217 | 218 | resource agentsEgressSubnet 'subnets' existing = { 219 | name: 'snet-agentsEgress' 220 | } 221 | } 222 | 223 | @description('The App Gateway subnet NSG') 224 | resource appGatewaySubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 225 | name: 'nsg-appGatewaySubnet' 226 | location: location 227 | properties: { 228 | securityRules: [ 229 | { 230 | name: 'AppGw.In.Allow.ControlPlane' 231 | properties: { 232 | description: 'Allow inbound Control Plane (https://docs.microsoft.com/azure/application-gateway/configuration-infrastructure#network-security-groups)' 233 | protocol: '*' 234 | sourcePortRange: '*' 235 | destinationPortRange: '65200-65535' 236 | sourceAddressPrefix: '*' 237 | destinationAddressPrefix: '*' 238 | access: 'Allow' 239 | priority: 100 240 | direction: 'Inbound' 241 | } 242 | } 243 | { 244 | name: 'AppGw.In.Allow443.Internet' 245 | properties: { 246 | description: 'Allow ALL inbound web traffic on port 443' 247 | protocol: 'Tcp' 248 | sourcePortRange: '*' 249 | destinationPortRange: '443' 250 | sourceAddressPrefix: 'Internet' 251 | destinationAddressPrefix: appGatewaySubnetPrefix 252 | access: 'Allow' 253 | priority: 110 254 | direction: 'Inbound' 255 | } 256 | } 257 | { 258 | name: 'AppGw.In.Allow.LoadBalancer' 259 | properties: { 260 | description: 'Allow inbound traffic from azure load balancer' 261 | protocol: '*' 262 | sourcePortRange: '*' 263 | destinationPortRange: '*' 264 | sourceAddressPrefix: 'AzureLoadBalancer' 265 | destinationAddressPrefix: '*' 266 | access: 'Allow' 267 | priority: 120 268 | direction: 'Inbound' 269 | } 270 | } 271 | { 272 | name: 'DenyAllInBound' 273 | properties: { 274 | protocol: '*' 275 | sourcePortRange: '*' 276 | sourceAddressPrefix: '*' 277 | destinationPortRange: '*' 278 | destinationAddressPrefix: '*' 279 | access: 'Deny' 280 | priority: 1000 281 | direction: 'Inbound' 282 | } 283 | } 284 | { 285 | name: 'AppGw.Out.Allow.PrivateEndpoints' 286 | properties: { 287 | description: 'Allow outbound traffic from the App Gateway subnet to the Private Endpoints subnet.' 288 | protocol: '*' 289 | sourcePortRange: '*' 290 | destinationPortRange: '*' 291 | sourceAddressPrefix: appGatewaySubnetPrefix 292 | destinationAddressPrefix: privateEndpointsSubnetPrefix 293 | access: 'Allow' 294 | priority: 100 295 | direction: 'Outbound' 296 | } 297 | } 298 | { 299 | name: 'AppPlan.Out.Allow.AzureMonitor' 300 | properties: { 301 | description: 'Allow outbound traffic from the App Gateway subnet to Azure Monitor' 302 | protocol: '*' 303 | sourcePortRange: '*' 304 | destinationPortRange: '*' 305 | sourceAddressPrefix: appGatewaySubnetPrefix 306 | destinationAddressPrefix: 'AzureMonitor' 307 | access: 'Allow' 308 | priority: 110 309 | direction: 'Outbound' 310 | } 311 | } 312 | ] 313 | } 314 | } 315 | 316 | @description('The App Service subnet NSG') 317 | resource appServiceSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 318 | name: 'nsg-appServicesSubnet' 319 | location: location 320 | properties: { 321 | securityRules: [ 322 | { 323 | name: 'AppPlan.Out.Allow.PrivateEndpoints' 324 | properties: { 325 | description: 'Allow outbound traffic from the app service subnet to the private endpoints subnet' 326 | protocol: 'Tcp' 327 | sourcePortRange: '*' 328 | destinationPortRange: '443' 329 | sourceAddressPrefix: appServicesSubnetPrefix 330 | destinationAddressPrefix: privateEndpointsSubnetPrefix 331 | access: 'Allow' 332 | priority: 100 333 | direction: 'Outbound' 334 | } 335 | } 336 | { 337 | name: 'AppPlan.Out.Allow.AzureMonitor' 338 | properties: { 339 | description: 'Allow outbound traffic from App service to the AzureMonitor ServiceTag.' 340 | protocol: '*' 341 | sourcePortRange: '*' 342 | destinationPortRange: '*' 343 | sourceAddressPrefix: appServicesSubnetPrefix 344 | destinationAddressPrefix: 'AzureMonitor' 345 | access: 'Allow' 346 | priority: 110 347 | direction: 'Outbound' 348 | } 349 | } 350 | ] 351 | } 352 | } 353 | 354 | @description('The Private endpoints subnet NSG') 355 | resource privateEndpointsSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 356 | name: 'nsg-privateEndpointsSubnet' 357 | location: location 358 | properties: { 359 | securityRules: [ 360 | { 361 | name: 'DenyAllOutBound' 362 | properties: { 363 | description: 'Deny outbound traffic from the private endpoints subnet' 364 | protocol: '*' 365 | sourcePortRange: '*' 366 | destinationPortRange: '*' 367 | sourceAddressPrefix: privateEndpointsSubnetPrefix 368 | destinationAddressPrefix: '*' 369 | access: 'Deny' 370 | priority: 1000 371 | direction: 'Outbound' 372 | } 373 | } 374 | ] 375 | } 376 | } 377 | 378 | @description('The Build agents subnet NSG') 379 | resource buildAgentsSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 380 | name: 'nsg-buildAgentsSubnet' 381 | location: location 382 | properties: { 383 | securityRules: [ 384 | { 385 | name: 'DenyAllOutBound' 386 | properties: { 387 | description: 'Deny outbound traffic from the build agents subnet. Note: adjust rules as needed based on the resources added to the subnet' 388 | protocol: '*' 389 | sourcePortRange: '*' 390 | destinationPortRange: '*' 391 | sourceAddressPrefix: buildAgentsSubnetPrefix 392 | destinationAddressPrefix: '*' 393 | access: 'Deny' 394 | priority: 1000 395 | direction: 'Outbound' 396 | } 397 | } 398 | ] 399 | } 400 | } 401 | 402 | @description('The Azure AI Agent service egress subnet NSG') 403 | resource azureAiAgentServiceSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 404 | name: 'nsg-agentsEgressSubnet' 405 | location: location 406 | properties: { 407 | securityRules: [ 408 | { 409 | name: 'DenyAllInBound' 410 | properties: { 411 | protocol: '*' 412 | sourcePortRange: '*' 413 | sourceAddressPrefix: '*' 414 | destinationPortRange: '*' 415 | destinationAddressPrefix: '*' 416 | access: 'Deny' 417 | priority: 1000 418 | direction: 'Inbound' 419 | } 420 | } 421 | { 422 | name: 'Agents.Out.Allow.PrivateEndpoints' 423 | properties: { 424 | description: 'Allow outbound traffic from the AI Agent egress subnet to the Private Endpoints subnet.' 425 | protocol: '*' 426 | sourcePortRange: '*' 427 | destinationPortRange: '*' 428 | sourceAddressPrefix: aiAgentsEgressSubnetPrefix 429 | destinationAddressPrefix: privateEndpointsSubnetPrefix 430 | access: 'Allow' 431 | priority: 100 432 | direction: 'Outbound' 433 | } 434 | } 435 | { 436 | name: 'Agents.Out.AllowTcp443.Internet' 437 | properties: { 438 | description: 'Allow outbound traffic from the AI Agent egress subnet to Internet on 443 (Azure firewall to filter further)' 439 | protocol: 'Tcp' 440 | sourcePortRange: '*' 441 | destinationPortRange: '443' 442 | sourceAddressPrefix: aiAgentsEgressSubnetPrefix 443 | destinationAddressPrefix: 'Internet' 444 | access: 'Allow' 445 | priority: 110 446 | direction: 'Outbound' 447 | } 448 | } 449 | { 450 | name: 'DenyAllOutBound' 451 | properties: { 452 | description: 'Deny all other outbound traffic from the Azure AI Agent subnet.' 453 | protocol: '*' 454 | sourcePortRange: '*' 455 | destinationPortRange: '*' 456 | sourceAddressPrefix: aiAgentsEgressSubnetPrefix 457 | destinationAddressPrefix: '*' 458 | access: 'Deny' 459 | priority: 1000 460 | direction: 'Outbound' 461 | } 462 | } 463 | ] 464 | } 465 | } 466 | 467 | // Bastion host subnet NSG 468 | // https://learn.microsoft.com/azure/bastion/bastion-nsg 469 | // https://github.com/Azure/azure-quickstart-templates/blob/master/quickstarts/microsoft.network/azure-bastion-nsg/main.bicep 470 | resource bastionSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 471 | name: 'nsg-bastionSubnet' 472 | location: location 473 | properties: { 474 | securityRules: [ 475 | { 476 | name: 'Bastion.In.Allow.Https' 477 | properties: { 478 | description: 'Allow inbound Https traffic from the from the Internet to the Bastion Host' 479 | protocol: 'Tcp' 480 | sourcePortRange: '*' 481 | sourceAddressPrefix: 'Internet' 482 | destinationPortRange: '443' 483 | destinationAddressPrefix: '*' 484 | access: 'Allow' 485 | priority: 100 486 | direction: 'Inbound' 487 | } 488 | } 489 | { 490 | name: 'Bastion.In.Allow.GatewayManager' 491 | properties: { 492 | protocol: 'Tcp' 493 | sourcePortRange: '*' 494 | sourceAddressPrefix: 'GatewayManager' 495 | destinationPortRanges: [ 496 | '443' 497 | '4443' 498 | ] 499 | destinationAddressPrefix: '*' 500 | access: 'Allow' 501 | priority: 110 502 | direction: 'Inbound' 503 | } 504 | } 505 | { 506 | name: 'Bastion.In.Allow.LoadBalancer' 507 | properties: { 508 | protocol: 'Tcp' 509 | sourcePortRange: '*' 510 | sourceAddressPrefix: 'AzureLoadBalancer' 511 | destinationPortRange: '443' 512 | destinationAddressPrefix: '*' 513 | access: 'Allow' 514 | priority: 120 515 | direction: 'Inbound' 516 | } 517 | } 518 | { 519 | name: 'Bastion.In.Allow.BastionHostCommunication' 520 | properties: { 521 | protocol: '*' 522 | sourcePortRange: '*' 523 | sourceAddressPrefix: 'VirtualNetwork' 524 | destinationPortRanges: [ 525 | '8080' 526 | '5701' 527 | ] 528 | destinationAddressPrefix: 'VirtualNetwork' 529 | access: 'Allow' 530 | priority: 130 531 | direction: 'Inbound' 532 | } 533 | } 534 | { 535 | name: 'DenyAllInBound' 536 | properties: { 537 | protocol: '*' 538 | sourcePortRange: '*' 539 | sourceAddressPrefix: '*' 540 | destinationPortRange: '*' 541 | destinationAddressPrefix: '*' 542 | access: 'Deny' 543 | priority: 1000 544 | direction: 'Inbound' 545 | } 546 | } 547 | { 548 | name: 'Bastion.Out.Allow.SshRdp' 549 | properties: { 550 | description: 'Allow outbound RDP and SSH from the Bastion Host subnet to elsewhere in the vnet' 551 | protocol: 'Tcp' 552 | sourcePortRange: '*' 553 | sourceAddressPrefix: '*' 554 | destinationPortRanges: [ 555 | '22' 556 | '3389' 557 | ] 558 | destinationAddressPrefix: 'VirtualNetwork' 559 | access: 'Allow' 560 | priority: 100 561 | direction: 'Outbound' 562 | } 563 | } 564 | { 565 | name: 'Bastion.Out.Allow.AzureMonitor' 566 | properties: { 567 | description: 'Allow outbound traffic from the Bastion Host subnet to Azure Monitor' 568 | protocol: '*' 569 | sourcePortRange: '*' 570 | destinationPortRange: '*' 571 | sourceAddressPrefix: bastionSubnetPrefix 572 | destinationAddressPrefix: 'AzureMonitor' 573 | access: 'Allow' 574 | priority: 110 575 | direction: 'Outbound' 576 | } 577 | } 578 | { 579 | name: 'Bastion.Out.Allow.AzureCloudCommunication' 580 | properties: { 581 | protocol: 'Tcp' 582 | sourcePortRange: '*' 583 | sourceAddressPrefix: '*' 584 | destinationPortRange: '443' 585 | destinationAddressPrefix: 'AzureCloud' 586 | access: 'Allow' 587 | priority: 120 588 | direction: 'Outbound' 589 | } 590 | } 591 | { 592 | name: 'Bastion.Out.Allow.BastionHostCommunication' 593 | properties: { 594 | protocol: '*' 595 | sourcePortRange: '*' 596 | sourceAddressPrefix: 'VirtualNetwork' 597 | destinationPortRanges: [ 598 | '8080' 599 | '5701' 600 | ] 601 | destinationAddressPrefix: 'VirtualNetwork' 602 | access: 'Allow' 603 | priority: 130 604 | direction: 'Outbound' 605 | } 606 | } 607 | { 608 | name: 'Bastion.Out.Allow.GetSessionInformation' 609 | properties: { 610 | protocol: '*' 611 | sourcePortRange: '*' 612 | sourceAddressPrefix: '*' 613 | destinationAddressPrefix: 'Internet' 614 | destinationPortRanges: [ 615 | '80' 616 | '443' 617 | ] 618 | access: 'Allow' 619 | priority: 140 620 | direction: 'Outbound' 621 | } 622 | } 623 | { 624 | name: 'DenyAllOutBound' 625 | properties: { 626 | protocol: '*' 627 | sourcePortRange: '*' 628 | destinationPortRange: '*' 629 | sourceAddressPrefix: '*' 630 | destinationAddressPrefix: '*' 631 | access: 'Deny' 632 | priority: 1000 633 | direction: 'Outbound' 634 | } 635 | } 636 | ] 637 | } 638 | } 639 | 640 | @description('The Jump box subnet NSG') 641 | resource jumpBoxSubnetNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { 642 | name: 'nsg-jumpBoxesSubnet' 643 | location: location 644 | properties: { 645 | securityRules: [ 646 | { 647 | name: 'JumpBox.In.Allow.SshRdp' 648 | properties: { 649 | description: 'Allow inbound RDP and SSH from the Bastion Host subnet' 650 | protocol: 'Tcp' 651 | sourcePortRange: '*' 652 | sourceAddressPrefix: bastionSubnetPrefix 653 | destinationPortRanges: [ 654 | '22' 655 | '3389' 656 | ] 657 | destinationAddressPrefix: jumpBoxSubnetPrefix 658 | access: 'Allow' 659 | priority: 100 660 | direction: 'Inbound' 661 | } 662 | } 663 | { 664 | name: 'JumpBox.Out.Allow.PrivateEndpoints' 665 | properties: { 666 | description: 'Allow outbound traffic from the jump box subnet to the Private Endpoints subnet.' 667 | protocol: '*' 668 | sourcePortRange: '*' 669 | destinationPortRange: '*' 670 | sourceAddressPrefix: jumpBoxSubnetPrefix 671 | destinationAddressPrefix: privateEndpointsSubnetPrefix 672 | access: 'Allow' 673 | priority: 100 674 | direction: 'Outbound' 675 | } 676 | } 677 | { 678 | name: 'JumpBox.Out.Allow.Internet' 679 | properties: { 680 | description: 'Allow outbound traffic from all VMs to Internet' 681 | protocol: '*' 682 | sourcePortRange: '*' 683 | destinationPortRange: '*' 684 | sourceAddressPrefix: jumpBoxSubnetPrefix 685 | destinationAddressPrefix: 'Internet' 686 | access: 'Allow' 687 | priority: 130 688 | direction: 'Outbound' 689 | } 690 | } 691 | { 692 | name: 'DenyAllOutBound' 693 | properties: { 694 | protocol: '*' 695 | sourcePortRange: '*' 696 | destinationPortRange: '*' 697 | sourceAddressPrefix: jumpBoxSubnetPrefix 698 | destinationAddressPrefix: '*' 699 | access: 'Deny' 700 | priority: 1000 701 | direction: 'Outbound' 702 | } 703 | } 704 | ] 705 | } 706 | } 707 | 708 | @description('Placeholder route table for egress traffic from subnets that we want to control routing for. When the firewall is created, the routes will be added.') 709 | resource egressRouteTable 'Microsoft.Network/routeTables@2024-05-01' = { 710 | name: 'udr-internet-to-firewall' 711 | location: location 712 | properties: { 713 | disableBgpRoutePropagation: true 714 | } 715 | } 716 | 717 | // Create and link Private DNS Zones used in this workload 718 | 719 | @description('Azure AI Foundry related private DNS zone') 720 | resource cognitiveServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 721 | name: 'privatelink.cognitiveservices.azure.com' 722 | location: 'global' 723 | properties: {} 724 | 725 | resource link 'virtualNetworkLinks' = { 726 | name: 'cognitiveservices' 727 | location: 'global' 728 | properties: { 729 | virtualNetwork: { 730 | id: virtualNetwork.id 731 | } 732 | registrationEnabled: false 733 | } 734 | } 735 | } 736 | 737 | @description('Azure AI Foundry related private DNS zone') 738 | resource aiFoundryPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 739 | name: 'privatelink.services.ai.azure.com' 740 | location: 'global' 741 | properties: {} 742 | 743 | resource link 'virtualNetworkLinks' = { 744 | name: 'aifoundry' 745 | location: 'global' 746 | properties: { 747 | virtualNetwork: { 748 | id: virtualNetwork.id 749 | } 750 | registrationEnabled: false 751 | } 752 | } 753 | } 754 | 755 | @description('Azure AI Foundry related private DNS zone') 756 | resource azureOpenAiPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 757 | name: 'privatelink.openai.azure.com' 758 | location: 'global' 759 | properties: {} 760 | 761 | resource link 'virtualNetworkLinks' = { 762 | name: 'azureopenai' 763 | location: 'global' 764 | properties: { 765 | virtualNetwork: { 766 | id: virtualNetwork.id 767 | } 768 | registrationEnabled: false 769 | } 770 | } 771 | } 772 | 773 | @description('Azure AI Search private DNS zone') 774 | resource aiSearchPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 775 | name: 'privatelink.search.windows.net' 776 | location: 'global' 777 | properties: {} 778 | 779 | resource link 'virtualNetworkLinks' = { 780 | name: 'aisearch' 781 | location: 'global' 782 | properties: { 783 | virtualNetwork: { 784 | id: virtualNetwork.id 785 | } 786 | registrationEnabled: false 787 | } 788 | } 789 | } 790 | 791 | @description('Blob Storage private DNS zone') 792 | resource blobStoragePrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 793 | name: 'privatelink.blob.${environment().suffixes.storage}' 794 | location: 'global' 795 | properties: {} 796 | 797 | resource link 'virtualNetworkLinks' = { 798 | name: 'blobstorage' 799 | location: 'global' 800 | properties: { 801 | virtualNetwork: { 802 | id: virtualNetwork.id 803 | } 804 | registrationEnabled: false 805 | } 806 | } 807 | } 808 | 809 | @description('Cosmos DB private DNS zone') 810 | resource cosmosDbPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 811 | name: 'privatelink.documents.azure.com' 812 | location: 'global' 813 | properties: {} 814 | 815 | resource link 'virtualNetworkLinks' = { 816 | name: 'cosmosdb' 817 | location: 'global' 818 | properties: { 819 | virtualNetwork: { 820 | id: virtualNetwork.id 821 | } 822 | registrationEnabled: false 823 | } 824 | } 825 | } 826 | 827 | @description('Azure Key Vault private DNS zone') 828 | resource keyVaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 829 | name: 'privatelink.vaultcore.azure.net' //Cannot use 'privatelink.${environment().suffixes.keyvaultDns}', per https://github.com/Azure/bicep/issues/9708 830 | location: 'global' 831 | properties: {} 832 | 833 | resource link 'virtualNetworkLinks' = { 834 | name: 'keyvault' 835 | location: 'global' 836 | properties: { 837 | virtualNetwork: { 838 | id: virtualNetwork.id 839 | } 840 | registrationEnabled: false 841 | } 842 | } 843 | } 844 | 845 | resource appServicePrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { 846 | name: 'privatelink.azurewebsites.net' 847 | location: 'global' 848 | properties: {} 849 | 850 | resource link 'virtualNetworkLinks' = { 851 | name: 'webapp' 852 | location: 'global' 853 | properties: { 854 | virtualNetwork: { 855 | id: virtualNetwork.id 856 | } 857 | registrationEnabled: false 858 | } 859 | } 860 | } 861 | 862 | // ---- Outputs ---- 863 | 864 | @description('The name of the virtual network.') 865 | output virtualNetworkName string = virtualNetwork.name 866 | 867 | @description('The name of the app service plan subnet.') 868 | output appServicesSubnetName string = virtualNetwork::appServiceSubnet.name 869 | 870 | @description('The name of the Azure Application Gateway subnet.') 871 | output applicationGatewaySubnetName string = virtualNetwork::appGatewaySubnet.name 872 | 873 | @description('The name of the private endpoints subnet.') 874 | output privateEndpointsSubnetName string = virtualNetwork::privateEndpointsSubnet.name 875 | 876 | @description('The name of the jump boxes subnet.') 877 | output jumpBoxesSubnetName string = virtualNetwork::jumpBoxSubnet.name 878 | 879 | @description('The name of the build agents subnet.') 880 | output buildAgentsSubnetName string = virtualNetwork::buildAgentsSubnet.name 881 | 882 | @description('The name of the Azure AI Agents egress subnet.') 883 | output agentsEgressSubnetName string = virtualNetwork::agentsEgressSubnet.name 884 | 885 | @description('The resource ID of the Azure AI Agents egress subnet.') 886 | output agentsEgressSubnetResourceId string = virtualNetwork::agentsEgressSubnet.id 887 | 888 | @description('The resource ID of the private endpoints subnet.') 889 | output privateEndpointsSubnetResourceId string = virtualNetwork::privateEndpointsSubnet.id 890 | 891 | @description('The name of the subnet for jump boxes.') 892 | output jumpBoxSubnetName string = virtualNetwork::jumpBoxSubnet.name 893 | -------------------------------------------------------------------------------- /infra-as-code/bicep/web-app-storage.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | /* 4 | Deploy an Azure Storage account used for the web app with private endpoint and private DNS zone 5 | */ 6 | 7 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 8 | @minLength(1) 9 | param location string = resourceGroup().location 10 | 11 | @description('This is the base name for each Azure resource name (6-8 chars)') 12 | @minLength(6) 13 | @maxLength(8) 14 | param baseName string 15 | 16 | @description('The name of the workload\'s existing Log Analytics workspace.') 17 | @minLength(4) 18 | param logAnalyticsWorkspaceName string 19 | 20 | @description('The name of the workload\'s virtual network in this resource group, the Azure Storage private endpoint will be deployed into a subnet in here.') 21 | @minLength(1) 22 | param virtualNetworkName string 23 | 24 | @description('The name for the subnet that private endpoints in the workload should surface in.') 25 | @minLength(1) 26 | param privateEndpointsSubnetName string 27 | 28 | @description('Assign your user some roles to support access to the Azure AI Agent dependencies for troubleshooting post deployment') 29 | @maxLength(36) 30 | @minLength(36) 31 | param debugUserPrincipalId string 32 | 33 | // ---- Existing resources ---- 34 | 35 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 36 | name: virtualNetworkName 37 | 38 | resource privateEndpointsSubnet 'subnets' existing = { 39 | name: privateEndpointsSubnetName 40 | } 41 | } 42 | 43 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 44 | name: logAnalyticsWorkspaceName 45 | } 46 | 47 | resource blobStorageLinkedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 48 | name: 'privatelink.blob.${environment().suffixes.storage}' 49 | } 50 | 51 | @description('Built-in Role: [Storage Blob Data Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#storage-blob-data-contributor)') 52 | resource storageBlobDataContributorRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 53 | name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' 54 | scope: subscription() 55 | } 56 | 57 | // ---- New resources ---- 58 | 59 | @description('Deploy a storage account for the web app to use as a deployment source for its web application code. Will be exposed only via private endpoint.') 60 | resource appDeployStorage 'Microsoft.Storage/storageAccounts@2024-01-01' = { 61 | name: 'stwebapp${baseName}' 62 | location: location 63 | sku: { 64 | name: 'Standard_ZRS' 65 | } 66 | kind: 'StorageV2' 67 | properties: { 68 | allowedCopyScope: 'AAD' 69 | accessTier: 'Hot' 70 | allowBlobPublicAccess: false 71 | allowSharedKeyAccess: false 72 | allowCrossTenantReplication: false 73 | encryption: { 74 | keySource: 'Microsoft.Storage' 75 | requireInfrastructureEncryption: false // This app service code host doesn't require double encryption, but if your scenario does, please enable. 76 | services: { 77 | blob: { 78 | enabled: true 79 | keyType: 'Account' 80 | } 81 | file: { 82 | enabled: true 83 | keyType: 'Account' 84 | } 85 | } 86 | } 87 | minimumTlsVersion: 'TLS1_2' 88 | isHnsEnabled: false 89 | isSftpEnabled: false 90 | defaultToOAuthAuthentication: true 91 | isLocalUserEnabled: false 92 | publicNetworkAccess: 'Disabled' 93 | networkAcls: { 94 | bypass: 'AzureServices' 95 | defaultAction: 'Deny' 96 | ipRules: [] 97 | virtualNetworkRules: [] 98 | } 99 | supportsHttpsTrafficOnly: true 100 | } 101 | 102 | resource blobService 'blobServices' = { 103 | name: 'default' 104 | 105 | // Storage container in which the Chat UI App's "Run from Zip" will be sourced 106 | resource deployContainer 'containers' = { 107 | name: 'deploy' 108 | properties: { 109 | publicAccess: 'None' 110 | } 111 | } 112 | } 113 | } 114 | 115 | @description('Enable App Service deployment Azure Storage Account blob diagnostic settings') 116 | resource azureDiagnosticsBlob 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 117 | name: 'default' 118 | scope: appDeployStorage::blobService 119 | properties: { 120 | workspaceId: logWorkspace.id 121 | logs: [ 122 | { 123 | category: 'StorageRead' 124 | enabled: true 125 | retentionPolicy: { 126 | enabled: false 127 | days: 0 128 | } 129 | } 130 | { 131 | category: 'StorageWrite' 132 | enabled: true 133 | retentionPolicy: { 134 | enabled: false 135 | days: 0 136 | } 137 | } 138 | { 139 | category: 'StorageDelete' 140 | enabled: true 141 | retentionPolicy: { 142 | enabled: false 143 | days: 0 144 | } 145 | } 146 | ] 147 | } 148 | } 149 | 150 | @description('Assign your user the ability to manage application deployment files in blob storage.') 151 | resource blobStorageContributorForUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 152 | scope: appDeployStorage::blobService::deployContainer 153 | name: guid(appDeployStorage::blobService::deployContainer.id, debugUserPrincipalId, storageBlobDataContributorRole.id) 154 | properties: { 155 | roleDefinitionId: storageBlobDataContributorRole.id 156 | principalType: 'User' 157 | principalId: debugUserPrincipalId // Part of the deployment guide requires you to upload the web app to this storage container. Assigning that data plane permission here. Ideally your CD pipeline would have this permission instead. 158 | } 159 | } 160 | 161 | resource webAppStoragePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 162 | name: 'pe-web-app-storage' 163 | location: location 164 | properties: { 165 | subnet: { 166 | id: virtualNetwork::privateEndpointsSubnet.id 167 | } 168 | customNetworkInterfaceName: 'nic-web-app-storage' 169 | privateLinkServiceConnections: [ 170 | { 171 | name: 'pe-web-app-storage' 172 | properties: { 173 | privateLinkServiceId: appDeployStorage.id 174 | groupIds: [ 175 | 'blob' 176 | ] 177 | } 178 | } 179 | ] 180 | } 181 | 182 | resource appDeployStorageDnsZoneGroup 'privateDnsZoneGroups' = { 183 | name: 'web-app-storage' 184 | properties: { 185 | privateDnsZoneConfigs: [ 186 | { 187 | name: 'web-app-storage' 188 | properties: { 189 | privateDnsZoneId: blobStorageLinkedPrivateDnsZone.id 190 | } 191 | } 192 | ] 193 | } 194 | } 195 | } 196 | 197 | // ---- Outputs ---- 198 | 199 | @description('The name of the appDeploy Azure Storage account.') 200 | output appDeployStorageName string = appDeployStorage.name 201 | -------------------------------------------------------------------------------- /infra-as-code/bicep/web-app.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'resourceGroup' 2 | 3 | /* 4 | Deploy a web app with a managed identity, diagnostic, and a private endpoint 5 | */ 6 | 7 | @description('This is the base name for each Azure resource name (6-8 chars)') 8 | @minLength(6) 9 | @maxLength(8) 10 | param baseName string 11 | 12 | @description('The region in which this architecture is deployed. Should match the region of the resource group.') 13 | @minLength(1) 14 | param location string = resourceGroup().location 15 | 16 | @description('The name of the workload\'s existing Log Analytics workspace.') 17 | @minLength(4) 18 | param logAnalyticsWorkspaceName string 19 | 20 | @description('The name of the web deploy file. The file should reside in a deploy container in the Azure Storage account. E.g. chatui.zip.') 21 | @minLength(5) 22 | param publishFileName string 23 | 24 | @description('The name of the existing virtual network that this Web App instance will be deployed into for egress and a private endpoint for ingress.') 25 | @minLength(1) 26 | param virtualNetworkName string 27 | 28 | @description('The name of the existing subnet in the virtual network that is where this web app will have its egress point.') 29 | @minLength(1) 30 | param appServicesSubnetName string 31 | 32 | @description('The name of the subnet that private endpoints in the workload should surface in.') 33 | @minLength(1) 34 | param privateEndpointsSubnetName string 35 | 36 | @description('The name of the existing Azure Storage account that the Azure Web App will be pulling code deployments from.') 37 | @minLength(3) 38 | param existingWebAppDeploymentStorageAccountName string 39 | 40 | @description('The name of the existing Azure Application Insights instance that the Azure Web App will be using.') 41 | @minLength(1) 42 | param existingWebApplicationInsightsResourceName string 43 | 44 | @description('The name of the existing Azure AI Foundry instance that the the Azure Web App code will be calling for Azure AI Agent service agents.') 45 | @minLength(2) 46 | param existingAzureAiFoundryResourceName string 47 | 48 | @description('The name of the existing Azure AI Foundry project name.') 49 | @minLength(2) 50 | param existingAzureAiFoundryProjectName string 51 | 52 | // variables 53 | var appName = 'app-${baseName}' 54 | 55 | // ---- Existing resources ---- 56 | 57 | @description('Existing Application Insights instance. Logs from the web app will be sent here.') 58 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { 59 | name: existingWebApplicationInsightsResourceName 60 | } 61 | 62 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { 63 | name: virtualNetworkName 64 | 65 | resource appServicesSubnet 'subnets' existing = { 66 | name: appServicesSubnetName 67 | } 68 | resource privateEndpointsSubnet 'subnets' existing = { 69 | name: privateEndpointsSubnetName 70 | } 71 | } 72 | 73 | @description('Existing Azure Storage account. This is where the web app code is deployed from.') 74 | resource webAppDeploymentStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { 75 | name: existingWebAppDeploymentStorageAccountName 76 | } 77 | 78 | resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { 79 | name: logAnalyticsWorkspaceName 80 | } 81 | 82 | @description('Built-in Role: [Storage Blob Data Reader](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#storage-blob-data-reader)') 83 | resource blobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 84 | name: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' 85 | scope: subscription() 86 | } 87 | 88 | @description('Built-in Role: [Azure AI User](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)') 89 | resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 90 | name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' 91 | scope: subscription() 92 | } 93 | 94 | // If your web app/API code is going to be creating agents dynamically, you will need to assign a role such as this to App Service managed identity. 95 | /*@description('Built-in Role: [Azure AI Project Manager](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)') 96 | resource azureAiProjectManagerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 97 | name: 'eadc314b-1a2d-4efa-be10-5d325db5065e' 98 | scope: subscription() 99 | }*/ 100 | 101 | resource appServiceExistingPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { 102 | name: 'privatelink.azurewebsites.net' 103 | } 104 | 105 | @description('Existing Azure AI Foundry account. This account is where the agents hosted in Azure AI Agent service will be deployed. The web app code calls to these agents.') 106 | resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { 107 | name: existingAzureAiFoundryResourceName 108 | 109 | resource project 'projects' existing = { 110 | name: existingAzureAiFoundryProjectName 111 | } 112 | } 113 | 114 | // ---- New resources ---- 115 | 116 | @description('Managed Identity for App Service') 117 | resource appServiceManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { 118 | name: 'id-${appName}' 119 | location: location 120 | } 121 | 122 | @description('Grant the App Service managed identity storage data reader role permissions') 123 | resource blobDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 124 | scope: webAppDeploymentStorageAccount 125 | name: guid(webAppDeploymentStorageAccount.id, appServiceManagedIdentity.id, blobDataReaderRole.id) 126 | properties: { 127 | roleDefinitionId: blobDataReaderRole.id 128 | principalType: 'ServicePrincipal' 129 | principalId: appServiceManagedIdentity.properties.principalId 130 | } 131 | } 132 | 133 | @description('Grant the App Service managed identity Azure AI user role permission so it can call into the Azure AI Foundry-hosted agent.') 134 | resource azureAiUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 135 | scope: aiFoundry 136 | name: guid(aiFoundry.id, appServiceManagedIdentity.id, azureAiUserRole.id) 137 | properties: { 138 | roleDefinitionId: azureAiUserRole.id 139 | principalType: 'ServicePrincipal' 140 | principalId: appServiceManagedIdentity.properties.principalId 141 | } 142 | } 143 | 144 | /*@description('Grant the App Service managed identity Azure AI manager role permission so it create the Azure AI Foundry-hosted agent. Only needed if your code creates agents directly.') 145 | resource azureAiManagerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 146 | scope: aiFoundry 147 | name: guid(aiFoundry.id, appServiceManagedIdentity.id, azureAiProjectManagerRole.id) 148 | properties: { 149 | roleDefinitionId: azureAiProjectManagerRole.id 150 | principalType: 'ServicePrincipal' 151 | principalId: appServiceManagedIdentity.properties.principalId 152 | } 153 | }*/ 154 | 155 | @description('Linux, PremiumV3 App Service Plan to host the chat web application.') 156 | resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { 157 | name: 'asp-${appName}${uniqueString(subscription().subscriptionId)}' 158 | location: location 159 | kind: 'linux' 160 | sku: { 161 | name: 'P1V3' // Some subscriptions do not have quota to premium web apps. If you encounter an error, request quota or to unblock yourself use 'S1' and set 'zoneRedundant' to 'false.' 162 | // az appservice list-locations --linux-workers-enabled --sku P1V3 163 | capacity: 3 164 | } 165 | properties: { 166 | zoneRedundant: true // Some subscriptions do not have quota to support zone redundancy. If you encounter an error, set this to false. 167 | reserved: true 168 | } 169 | } 170 | 171 | @description('This is the web app that contains the chat UI application.') 172 | resource webApp 'Microsoft.Web/sites@2024-04-01' = { 173 | name: appName 174 | location: location 175 | kind: 'app,linux' 176 | identity: { 177 | type: 'UserAssigned' 178 | userAssignedIdentities: { 179 | '${appServiceManagedIdentity.id}': {} 180 | } 181 | } 182 | properties: { 183 | enabled: true 184 | serverFarmId: appServicePlan.id 185 | virtualNetworkSubnetId: virtualNetwork::appServicesSubnet.id 186 | httpsOnly: true 187 | #disable-next-line BCP037 // This is a valid property, just not part of the schema https://github.com/Azure/bicep-types-az/issues/2204 188 | sshEnabled: false 189 | autoGeneratedDomainNameLabelScope: 'SubscriptionReuse' 190 | vnetContentShareEnabled: true 191 | vnetImagePullEnabled: true 192 | publicNetworkAccess: 'Disabled' 193 | keyVaultReferenceIdentity: appServiceManagedIdentity.id 194 | endToEndEncryptionEnabled: true 195 | vnetRouteAllEnabled: true 196 | hostNamesDisabled: false 197 | clientAffinityEnabled: false 198 | siteConfig: { 199 | ftpsState: 'Disabled' 200 | vnetRouteAllEnabled: true 201 | http20Enabled: false 202 | publicNetworkAccess: 'Disabled' 203 | alwaysOn: true 204 | linuxFxVersion: 'DOTNETCORE|8.0' 205 | netFrameworkVersion: null 206 | windowsFxVersion: null 207 | } 208 | } 209 | dependsOn: [ 210 | blobDataReaderRoleAssignment 211 | ] 212 | 213 | @description('Default configuration for the web app.') 214 | resource appsettings 'config' = { 215 | name: 'appsettings' 216 | properties: { 217 | WEBSITE_RUN_FROM_PACKAGE: '${webAppDeploymentStorageAccount.properties.primaryEndpoints.blob}deploy/${publishFileName}' 218 | WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID: appServiceManagedIdentity.id 219 | APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString 220 | AZURE_CLIENT_ID: appServiceManagedIdentity.properties.clientId 221 | ApplicationInsightsAgent_EXTENSION_VERSION: '~3' 222 | AIProjectEndpoint: aiFoundry::project.properties.endpoints['AI Foundry API'] 223 | AIAgentId: 'Not yet set' // Will be set once the agent is created 224 | XDT_MicrosoftApplicationInsights_Mode: 'Recommended' 225 | } 226 | } 227 | 228 | @description('Disable SCM publishing integration.') 229 | resource scm 'basicPublishingCredentialsPolicies' = { 230 | name: 'scm' 231 | properties: { 232 | allow: false 233 | } 234 | } 235 | 236 | @description('Disable FTP publishing integration.') 237 | resource ftp 'basicPublishingCredentialsPolicies' = { 238 | name: 'ftp' 239 | properties: { 240 | allow: false 241 | } 242 | } 243 | } 244 | 245 | @description('Enable App Service Azure Diagnostic') 246 | resource azureDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 247 | name: 'default' 248 | scope: webApp 249 | properties: { 250 | workspaceId: logWorkspace.id 251 | logs: [ 252 | { 253 | category: 'AppServiceHTTPLogs' 254 | enabled: true 255 | } 256 | { 257 | category: 'AppServiceConsoleLogs' 258 | enabled: true 259 | } 260 | { 261 | category: 'AppServiceAppLogs' 262 | enabled: true 263 | } 264 | { 265 | category: 'AppServicePlatformLogs' 266 | enabled: true 267 | } 268 | { 269 | category: 'AppServiceAuditLogs' 270 | enabled: true 271 | } 272 | { 273 | category: 'AppServiceIPSecAuditLogs' 274 | enabled: true 275 | } 276 | { 277 | category: 'AppServiceAuthenticationLogs' 278 | enabled: true 279 | } 280 | ] 281 | } 282 | } 283 | 284 | resource appServicePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { 285 | name: 'pe-front-end-web-app' 286 | location: location 287 | properties: { 288 | subnet: { 289 | id: virtualNetwork::privateEndpointsSubnet.id 290 | } 291 | customNetworkInterfaceName: 'nic-front-end-web-app' 292 | privateLinkServiceConnections: [ 293 | { 294 | name: 'front-end-web-app' 295 | properties: { 296 | privateLinkServiceId: webApp.id 297 | groupIds: [ 298 | 'sites' 299 | ] 300 | } 301 | } 302 | ] 303 | } 304 | 305 | resource appServiceDnsZoneGroup 'privateDnsZoneGroups' = { 306 | name: 'front-end-web-app' 307 | properties: { 308 | privateDnsZoneConfigs: [ 309 | { 310 | name: 'web-app' 311 | properties: { 312 | privateDnsZoneId: appServiceExistingPrivateDnsZone.id 313 | } 314 | } 315 | ] 316 | } 317 | } 318 | } 319 | 320 | // App service plan auto scale settings 321 | resource appServicePlanAutoScaleSettings 'Microsoft.Insights/autoscalesettings@2022-10-01' = { 322 | name: '${appServicePlan.name}-autoscale' 323 | location: location 324 | properties: { 325 | enabled: true 326 | targetResourceUri: appServicePlan.id 327 | profiles: [ 328 | { 329 | name: 'Scale out condition' 330 | capacity: { 331 | maximum: '5' 332 | default: '3' 333 | minimum: '3' 334 | } 335 | rules: [ 336 | { 337 | scaleAction: { 338 | type: 'ChangeCount' 339 | direction: 'Increase' 340 | cooldown: 'PT5M' 341 | value: '1' 342 | } 343 | metricTrigger: { 344 | metricName: 'CpuPercentage' 345 | metricNamespace: 'microsoft.web/serverfarms' 346 | operator: 'GreaterThan' 347 | timeAggregation: 'Average' 348 | threshold: 70 349 | metricResourceUri: appServicePlan.id 350 | timeWindow: 'PT10M' 351 | timeGrain: 'PT1M' 352 | statistic: 'Average' 353 | } 354 | } 355 | ] 356 | } 357 | ] 358 | } 359 | dependsOn: [ 360 | webApp 361 | ] 362 | } 363 | 364 | // ---- Outputs ---- 365 | 366 | @description('The name of the app service plan.') 367 | output appServicePlanName string = appServicePlan.name 368 | 369 | @description('The name of the web app.') 370 | output appName string = webApp.name 371 | -------------------------------------------------------------------------------- /website/chatui.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/website/chatui.zip -------------------------------------------------------------------------------- /website/chatui/Configuration/ChatApiOptions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace chatui.Configuration; 4 | 5 | public class ChatApiOptions 6 | { 7 | [Url] 8 | public string AIProjectEndpoint { get; init; } = default!; 9 | 10 | [Required] 11 | public string AIAgentId { get; init; } = default!; 12 | } -------------------------------------------------------------------------------- /website/chatui/Controllers/ChatController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Extensions.Options; 3 | using Azure; 4 | using Azure.AI.Agents.Persistent; 5 | using chatui.Configuration; 6 | 7 | namespace chatui.Controllers; 8 | 9 | [ApiController] 10 | [Route("[controller]/[action]")] 11 | 12 | public class ChatController( 13 | PersistentAgentsClient client, 14 | IOptionsMonitor options, 15 | ILogger logger) : ControllerBase 16 | { 17 | private readonly PersistentAgentsClient _client = client; 18 | private readonly IOptionsMonitor _options = options; 19 | private readonly ILogger _logger = logger; 20 | 21 | // TODO: [security] Do not trust client to provide threadId. Instead map current user to their active threadid in your application's own state store. 22 | // Without this security control in place, a user can inject messages into another user's thread. 23 | [HttpPost("{threadId}")] 24 | public async Task Completions([FromRoute] string threadId, [FromBody] string prompt) 25 | { 26 | if (string.IsNullOrWhiteSpace(prompt)) 27 | throw new ArgumentException("Prompt cannot be null, empty, or whitespace.", nameof(prompt)); 28 | 29 | _logger.LogDebug("Prompt received {Prompt}", prompt); 30 | var _config = _options.CurrentValue; 31 | 32 | PersistentThreadMessage message = await _client.Messages.CreateMessageAsync( 33 | threadId, 34 | MessageRole.User, 35 | prompt); 36 | 37 | ThreadRun run = await _client.Runs.CreateRunAsync(threadId, _config.AIAgentId); 38 | 39 | while (run.Status == RunStatus.Queued || run.Status == RunStatus.InProgress || run.Status == RunStatus.RequiresAction) 40 | { 41 | await Task.Delay(TimeSpan.FromMilliseconds(500)); 42 | run = (await _client.Runs.GetRunAsync(threadId, run.Id)).Value; 43 | } 44 | 45 | Pageable messages = _client.Messages.GetMessages( 46 | threadId: threadId, order: ListSortOrder.Ascending); 47 | 48 | var fullText = 49 | messages 50 | .Where(m => m.Role == MessageRole.Agent) 51 | .SelectMany(m => m.ContentItems.OfType()) 52 | .Last().Text; 53 | 54 | return Ok(new { data = fullText }); 55 | } 56 | 57 | [HttpPost] 58 | public async Task Threads() 59 | { 60 | // TODO [performance efficiency] Delay creating a thread until the first user message arrives. 61 | PersistentAgentThread thread = await _client.Threads.CreateThreadAsync(); 62 | 63 | return Ok(new { id = thread.Id }); 64 | } 65 | } -------------------------------------------------------------------------------- /website/chatui/Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace chatui.Controllers; 4 | 5 | public class HomeController : Controller 6 | { 7 | public IActionResult Index() 8 | { 9 | return View(); 10 | } 11 | } -------------------------------------------------------------------------------- /website/chatui/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using Azure.AI.Agents.Persistent; 3 | using Azure.Identity; 4 | using chatui.Configuration; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | builder.Services.AddOptions() 9 | .Bind(builder.Configuration) 10 | .ValidateDataAnnotations() 11 | .ValidateOnStart(); 12 | 13 | builder.Services.AddSingleton((provider) => 14 | { 15 | var config = provider.GetRequiredService>().Value; 16 | PersistentAgentsClient client = new(config.AIProjectEndpoint, new DefaultAzureCredential()); 17 | 18 | return client; 19 | }); 20 | 21 | builder.Services.AddControllersWithViews(); 22 | 23 | builder.Services.AddCors(options => 24 | { 25 | options.AddPolicy("AllowAllOrigins", 26 | builder => 27 | { 28 | builder.AllowAnyOrigin() 29 | .AllowAnyMethod() 30 | .AllowAnyHeader(); 31 | }); 32 | }); 33 | 34 | var app = builder.Build(); 35 | 36 | app.UseStaticFiles(); 37 | 38 | app.UseRouting(); 39 | 40 | app.MapControllerRoute( 41 | name: "default", 42 | pattern: "{controller=Home}/{action=Index}/{id?}"); 43 | 44 | app.UseCors("AllowAllOrigins"); 45 | 46 | app.Run(); -------------------------------------------------------------------------------- /website/chatui/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "launchUrl": "Home", 9 | "applicationUrl": "http://localhost:5064", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /website/chatui/Views/Home/Index.cshtml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 173 | 174 | 266 | 267 | 268 | 269 |
270 |
271 |

272 | 273 | Chat with your orchestrator 274 |

275 |
276 | 277 |
278 | 279 |
280 | 281 |
282 | 292 | 293 |
294 |
295 | 296 | 297 | -------------------------------------------------------------------------------- /website/chatui/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | }, 7 | "Console": { 8 | "LogLevel": { 9 | "Default": "Debug", 10 | "Microsoft": "Warning" 11 | } 12 | } 13 | }, 14 | "AllowedHosts": "*", 15 | "AIProjectEndpoint": "https://.services.ai.azure.com/api/projects/", 16 | "AIAgentId": "" 17 | } -------------------------------------------------------------------------------- /website/chatui/chatui.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /website/chatui/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-end-to-end-baseline/39d3dccdc055400f396065688a891d0db63f52cb/website/chatui/wwwroot/favicon.ico --------------------------------------------------------------------------------