Skip to content
40 changes: 40 additions & 0 deletions docs/10-private-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Securing Azure Chat Resources with Private Endpoints

## Overview

You can enhance the security of the Azure Chat application by using Private Endpoints. Private Endpoints provide a secure and private connection to Azure services, ensuring that traffic between the Web App that hosts the application and the supporting Azure services remains within the Microsoft network. Implementing Private Endpoints also allows for the removal of any public network access to the these services.

The included bicep template can optionally be configured to make a number of key changes to the deployed Azure resources to enable Private Endpoints:
1. Deploy a Virtual Network and 2 subnets - one for the Web App backend, and one for the Private Endpoints.
1. Deploy Private Endpoints for the following services:
- OpenAI Service
- Cosmos DB
- Storage Account
- AI Search Service
- AI Document Intelligence
- Key Vault
1. Configure the Web App to use the Virtual Network for outgoing requests
1. Remove public access to all of the above services - only clients and applications within the Virtual Network will be able to access these services.

![Private Endpoints image](/docs/images/private-endpoints.png)

Using Private Endpoints for these services is a recommended best practice for production deployments of Azure Chat, and it can also be useful in Azure environments where policies are in place to disable public access to some services. There are some additional considerations to be aware of when using Private Endpoints however:
- **Local Developemnt**: If you deploy the Azure resources with Private Endpoints it will be more difficult to use these services if you are running the application locally - you will need to use a development environment that is connected to the Virtual Network.
- **Resource Access in the Portal** - If you deploy the Azure resources with Private Endpoints you will need to use a development environment that is connected to the Virtual Network to access the data plane for any of these services (e.g. the Cosmos DB Azure Portal Data Explorer).

## How to enable Private Endpoints

The addition of Private Endpoints and it's supportted configuration is controlled by the `usePrivateEndpoints` parameter in the bicep template. To enable Private Endpoints, set this parameter to `true`. If you are using the Azure Developer CLI to deploy the application see the [Deploy to Azure](4-deploy-to-azure.md) page for more details on how to do this.

## Additional Configuration

By default the Virtual Network that is deployed when you set `usePrivateEndpoints` to `true` has the following properies:
- **Address Space**: `192.168.0.0/16`
- **Subnet for Private Endpoints**: `privateEndpoints` - `192.168.0.0/24`
- **Subnet for Web App**: `appServiceBackend` - `192.168.1.0/24`

The address spaces for each of the subnets can be changed by setting the `privateEndpointVNetPrefix`, `privateEndpointSubnetAddressPrefix` and `appServiceBackendSubnetAddressPrefix` parameters in the bicep template.

If you want to deploy these resources into an existing Virtual Network you will need to modify the `private_endpoints_core.bicep` template - a parameterised version of this is not currently available. If you do this note that the deployment includes Private DNS Zones for each of the services that are deployed with Private Endpoints - if your Virtual Network uses a custom DNS server you will need to ensure that the DNS server can resolve the Private DNS Zones.


16 changes: 16 additions & 0 deletions docs/4-deploy-to-azure.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ To deploy the application to Azure using the Azure Developer CLI, follow the ste
2. Run `azd auth login` to authenticate with Azure
3. Run `azd up` to provision and deploy the application

In both cases you will be prompted for some configuration values. These are described below:

| Prompt - azd init | Description |
|--------|-------------|
| Enter a new environment name &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;| The name of the azd environment. The application will be deployed into a resource group called `rg-<environment name>`, and this value is also used in the name of all the created Azure resources.|

| Prompt - azd up | Description |
|--------|-------------|
| Select an Azure Subscription to use| Select the Azure subscription you want to deploy the application to |
|Select an Azure location to use| Select the Azure region you want to deploy the Azure services to. This location is used by all services except OpenAI deployments - these are set separately (below) |
| Enter a value for the 'dalleLocation' infrastructure parameter| Select the Azure region you want to deploy the DALL-E model to. The number of regions that support DALL-E is currently limited |
| Enter a value for the 'disableLocalAuth' infrastructure parameter | Set to `true` to use Managed Identities for authentication, or `false` to use keys. See [Managed Identities](9-managed-identities.md) for more information. if you are unsure we recommend you select `true` |
| Enter a value for the 'openAILocation' infrastructure parameter: | Select the Azure region you want to deploy the OpenAI service to. |
|Enter a value for the 'usePrivateEndpoints' infrastructure parameter: | Set to `false` to deploy the application without Private Endpoints, or `true` to use Private Endpoints. See [Private Endpoints](10-private-endpoints.md) for more information. If you are unsure we recommend you select `false` |


## Option 2: GitHub Actions

The following steps describes how the application can be deployed to Azure App service using GitHub Actions.
Expand Down
Binary file added docs/images/private-endpoints.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 52 additions & 10 deletions infra/main.bicep
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
targetScope = 'subscription'

// Activates/Deactivates Authentication using keys. If true it will enforce RBAC using managed identities
param disableLocalAuth bool = false
@allowed([true, false])
@description('Enables/Disables Authentication using keys. If true it will enforce RBAC using managed identity and disable key auth on backend resouces')
param disableLocalAuth bool

@allowed([false, true])
@description('Enables/Disables Private Endpoints for backend Azure resources. If true, it will create a virtual network and subnets to host the private endpoints.')
param usePrivateEndpoints bool

@minLength(1)
@maxLength(64)
Expand All @@ -14,16 +20,49 @@ param location string

// azure open ai -- regions currently support gpt-4o global-standard
@description('Location for the OpenAI resource group')
@allowed(['australiaeast', 'brazilsouth', 'canadaeast', 'eastus', 'eastus2', 'francecentral', 'germanywestcentral', 'japaneast', 'koreacentral', 'northcentralus', 'norwayeast', 'polandcentral', 'spaincentral', 'southafricanorth', 'southcentralus', 'southindia', 'swedencentral', 'switzerlandnorth', 'uksouth', 'westeurope', 'westus', 'westus3'])
@allowed([
'australiaeast'
'brazilsouth'
'canadaeast'
'eastus'
'eastus2'
'francecentral'
'germanywestcentral'
'japaneast'
'koreacentral'
'northcentralus'
'norwayeast'
'polandcentral'
'spaincentral'
'southafricanorth'
'southcentralus'
'southindia'
'swedencentral'
'switzerlandnorth'
'uksouth'
'westeurope'
'westus'
'westus3'
])
@metadata({
azd: {
type: 'location'
}
})
param openAILocation string

// DALL-E v3 only supported in limited regions for now
@description('Location for the OpenAI DALL-E 3 instance resource group')
@allowed(['swedencentral', 'eastus', 'australiaeast'])
@metadata({
azd: {
type: 'location'
}
})
param dalleLocation string

param openAISku string = 'S0'
param openAIApiVersion string ='2024-08-01-preview'
param openAIApiVersion string = '2024-08-01-preview'

param chatGptDeploymentCapacity int = 30
param chatGptDeploymentName string = 'gpt-4o'
Expand All @@ -33,11 +72,6 @@ param embeddingDeploymentName string = 'embedding'
param embeddingDeploymentCapacity int = 120
param embeddingModelName string = 'text-embedding-ada-002'

// DALL-E v3 only supported in limited regions for now
@description('Location for the OpenAI DALL-E 3 instance resource group')
@allowed(['swedencentral', 'eastus', 'australiaeast'])
param dalleLocation string

param dalleDeploymentCapacity int = 1
param dalleDeploymentName string = 'dall-e-3'
param dalleModelName string = 'dall-e-3'
Expand All @@ -48,11 +82,15 @@ param searchServiceIndexName string = 'azure-chat'
param searchServiceSkuName string = 'standard'

// TODO: define good default Sku and settings for storage account
param storageServiceSku object = { name: 'Standard_LRS' }
param storageServiceSku object = { name: 'Standard_LRS' }
param storageServiceImageContainerName string = 'images'

param resourceGroupName string = ''

param privateEndpointVNetPrefix string = '192.168.0.0/16'
param privateEndpointSubnetAddressPrefix string = '192.168.0.0/24'
param appServiceBackendSubnetAddressPrefix string = '192.168.1.0/24'

var resourceToken = toLower(uniqueString(subscription().id, name, location))
var tags = { 'azd-env-name': name }

Expand Down Expand Up @@ -91,7 +129,11 @@ module resources 'resources.bicep' = {
storageServiceSku: storageServiceSku
storageServiceImageContainerName: storageServiceImageContainerName
location: location
disableLocalAuth:disableLocalAuth
disableLocalAuth: disableLocalAuth
usePrivateEndpoints: usePrivateEndpoints
privateEndpointVNetPrefix: privateEndpointVNetPrefix
privateEndpointSubnetAddressPrefix: privateEndpointSubnetAddressPrefix
appServiceBackendSubnetAddressPrefix: appServiceBackendSubnetAddressPrefix
}
}

Expand Down
158 changes: 158 additions & 0 deletions infra/private_endpoints_core.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
@minLength(1)
@description('Primary location for all resources')
param location string

param name string
param resourceToken string

param cosmos_id string
param openai_id string
param openai_dalle_id string
param form_recognizer_id string
// param speech_service_id string
param search_service_id string
param storage_id string
param keyVault_id string

param tags object

param privateEndpointVNetPrefix string = '192.168.0.0/16'
param privateEndpointSubnetAddressPrefix string = '192.168.0.0/24'
param appServiceBackendSubnetAddressPrefix string = '192.168.1.0/24'

var subnetNamePrivateEndpoints = 'privateEndpoints'
var subnetNameAppServiceBackend = 'appServiceBackend'

var virtualNetworkName = toLower('${name}-vnet-${resourceToken}')

var privateEndpointSpecs = [
{
serviceId: cosmos_id
dnsZoneName: 'privatelink.documents.azure.com'
groupId: 'Sql'
}
{
serviceId: openai_id
dnsZoneName: 'privatelink.openai.azure.com'
groupId: 'account'
}
{
serviceId: storage_id
dnsZoneName: 'privatelink.blob.core.windows.net'
groupId: 'blob'
}
{
serviceId: search_service_id
dnsZoneName: 'privatelink.search.windows.net'
groupId: 'searchService'
}
{
serviceId: keyVault_id
dnsZoneName: 'privatelink.vaultcore.azure.net'
groupId: 'vault'
}
{
serviceId: form_recognizer_id
dnsZoneName: 'privatelink.cognitiveservices.azure.com'
groupId: 'account'
}
// speech service is called from the browser so no private endpoint
// {
// serviceId: speech_service_id
// dnsZoneName: 'privatelink.cognitiveservices.azure.com'
// groupId: 'account'
// }
]

// specified separately so that we can ensure the private DNS zones are created before these private endpoints
var privateEndpointSpecs_noDNSZone = [
{
serviceId: openai_dalle_id
dnsZoneName: 'privatelink.openai.azure.com'
groupId: 'account'
}
]

resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2021-08-01' = {
name: toLower('${name}-nsg-${resourceToken}')
location: location
tags: tags
}

resource virtualNetwork 'Microsoft.Network/VirtualNetworks@2021-08-01' = {
name: virtualNetworkName
location: location
tags: tags
properties: {
addressSpace: {
addressPrefixes: [
privateEndpointVNetPrefix
]
}
}
}

resource subnet_privateEndpoint 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = {
parent: virtualNetwork
name: subnetNamePrivateEndpoints
properties: {
addressPrefix: privateEndpointSubnetAddressPrefix
privateEndpointNetworkPolicies: 'Disabled'
networkSecurityGroup: {
id: networkSecurityGroup.id
}
}
}

resource subnet_appServiceBackend 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = {
parent: virtualNetwork
name: subnetNameAppServiceBackend
properties: {
addressPrefix: appServiceBackendSubnetAddressPrefix
delegations: [
{
name: 'delegation'
properties: {
serviceName: 'Microsoft.Web/serverFarms'
}
}
]
networkSecurityGroup: {
id: networkSecurityGroup.id
}
}
}

module privateEndpoints 'private_endpoints_services.bicep' = [
for (privateEndpointSpec, i) in privateEndpointSpecs: {
name: 'private-endpoint-${i}'
params: {
serviceId: privateEndpointSpec.serviceId
dnsZoneName: privateEndpointSpec.dnsZoneName
createDnsZone: true
virtualNetworkId: virtualNetwork.id
privateEndpointSubnetId: subnet_privateEndpoint.id
groupId: privateEndpointSpec.groupId
}
}
]

// created after the previous private endpoints to ensure the private DNS zones are created first
module privateEndpoints_noDNSZone 'private_endpoints_services.bicep' = [
for (privateEndpointSpec, i) in privateEndpointSpecs_noDNSZone: {
name: 'private-endpoint-noDns-${i}'
dependsOn: [
privateEndpoints
]
params: {
serviceId: privateEndpointSpec.serviceId
dnsZoneName: privateEndpointSpec.dnsZoneName
createDnsZone: false
virtualNetworkId: virtualNetwork.id
privateEndpointSubnetId: subnet_privateEndpoint.id
groupId: privateEndpointSpec.groupId
}
}
]

output appServiceSubnetId string = subnet_appServiceBackend.id
65 changes: 65 additions & 0 deletions infra/private_endpoints_services.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
param serviceId string

param dnsZoneName string
param createDnsZone bool = true
param virtualNetworkId string
param privateEndpointSubnetId string
param groupId string

var idElements = split(serviceId, '/')
var idElementsLength = length(idElements)

var serviceName = idElementsLength == 1 ? serviceId : idElements[idElementsLength-1]

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-08-01' = {
name: toLower('${serviceName}-pe')
location: resourceGroup().location
properties: {
subnet: {
id: privateEndpointSubnetId
}
privateLinkServiceConnections: [
{
name: toLower('${serviceName}-pe-connections')
properties: {
privateLinkServiceId: serviceId
groupIds: [
groupId
]
}
}
]
}
}

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (createDnsZone) {
name: dnsZoneName
location: 'global'
}

resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (createDnsZone){
parent: privateDnsZone
name: '${privateDnsZone.name}-link'
location: 'global'
properties: {
registrationEnabled: false
virtualNetwork: {
id: virtualNetworkId
}
}
}

resource privateEndpointsDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-05-01' = {
name: '${serviceName}-dns-zone-group'
parent: privateEndpoint
properties: {
privateDnsZoneConfigs: [
{
name: dnsZoneName
properties: {
privateDnsZoneId: resourceId('Microsoft.Network/privateDnsZones', dnsZoneName)
}
}
]
}
}
Loading
Loading