├── .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 |
276 |
277 |
278 |
279 |
280 |
281 |
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
--------------------------------------------------------------------------------