diff --git a/README.md b/README.md index a3fec02..8cfef5f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,13 @@ Run this command to provision the function app, with any required Azure resource azd up ``` +By default, this sample prompts to enable a virtual network for enhanced security. If you want to deploy without a virtual network without prompting, you can configure `VNET_ENABLED` to `false` before running `azd up`: + +```bash +azd env set VNET_ENABLED false +azd up +``` + You're prompted to supply these required deployment parameters: | Parameter | Description | diff --git a/azure.yaml b/azure.yaml index 5153ed9..8328147 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,9 +2,9 @@ name: functions-quickstart-javascript-azd metadata: - template: functions-quickstart-javascript-azd@0.0.2-beta + template: functions-quickstart-javascript-azd@1.0.1 services: - processor: + api: project: . language: js host: function diff --git a/infra/app/api.bicep b/infra/app/api.bicep new file mode 100644 index 0000000..0b1e126 --- /dev/null +++ b/infra/app/api.bicep @@ -0,0 +1,109 @@ +param name string +@description('Primary location for all resources & Flex Consumption Function App') +param location string = resourceGroup().location +param tags object = {} +param applicationInsightsName string = '' +param appServicePlanId string +param appSettings object = {} +param runtimeName string +param runtimeVersion string +param serviceName string = 'api' +param storageAccountName string +param deploymentStorageContainerName string +param virtualNetworkSubnetId string = '' +param instanceMemoryMB int = 2048 +param maximumInstanceCount int = 100 +param identityId string = '' +param identityClientId string = '' +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false +param enableFile bool = false + +@allowed(['SystemAssigned', 'UserAssigned']) +param identityType string = 'UserAssigned' + +var applicationInsightsIdentity = 'ClientId=${identityClientId};Authorization=AAD' +var kind = 'functionapp,linux' + +// Create base application settings +var baseAppSettings = { + // Only include required credential settings unconditionally + AzureWebJobsStorage__credential: 'managedidentity' + AzureWebJobsStorage__clientId: identityClientId + + // Application Insights settings are always included + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString +} + +// Dynamically build storage endpoint settings based on feature flags +var blobSettings = enableBlob ? { AzureWebJobsStorage__blobServiceUri: stg.properties.primaryEndpoints.blob } : {} +var queueSettings = enableQueue ? { AzureWebJobsStorage__queueServiceUri: stg.properties.primaryEndpoints.queue } : {} +var tableSettings = enableTable ? { AzureWebJobsStorage__tableServiceUri: stg.properties.primaryEndpoints.table } : {} +var fileSettings = enableFile ? { AzureWebJobsStorage__fileServiceUri: stg.properties.primaryEndpoints.file } : {} + +// Merge all app settings +var allAppSettings = union( + appSettings, + blobSettings, + queueSettings, + tableSettings, + fileSettings, + baseAppSettings +) + +resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { + name: applicationInsightsName +} + +// Create a Flex Consumption Function App to host the API +module api 'br/public:avm/res/web/site:0.15.1' = { + name: '${serviceName}-flex-consumption' + params: { + kind: kind + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + serverFarmResourceId: appServicePlanId + managedIdentities: { + systemAssigned: identityType == 'SystemAssigned' + userAssignedResourceIds: [ + '${identityId}' + ] + } + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${stg.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' + authentication: { + type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' + userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' + } + } + } + scaleAndConcurrency: { + instanceMemoryMB: instanceMemoryMB + maximumInstanceCount: maximumInstanceCount + } + runtime: { + name: runtimeName + version: runtimeVersion + } + } + siteConfig: { + alwaysOn: false + } + virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null + appSettingsKeyValuePairs: allAppSettings + } +} + +output SERVICE_API_NAME string = api.outputs.name +// Ensure output is always string, handle potential null from module output if SystemAssigned is not used +output SERVICE_API_IDENTITY_PRINCIPAL_ID string = identityType == 'SystemAssigned' ? api.outputs.?systemAssignedMIPrincipalId ?? '' : '' diff --git a/infra/app/processor.bicep b/infra/app/processor.bicep deleted file mode 100644 index 17ebd2b..0000000 --- a/infra/app/processor.bicep +++ /dev/null @@ -1,44 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} -param applicationInsightsName string = '' -param appServicePlanId string -param appSettings object = {} -param runtimeName string -param runtimeVersion string -param serviceName string = 'processor' -param storageAccountName string -param virtualNetworkSubnetId string = '' -param instanceMemoryMB int = 2048 -param maximumInstanceCount int = 100 -param identityId string = '' -param identityClientId string = '' - -var applicationInsightsIdentity = 'ClientId=${identityClientId};Authorization=AAD' - -module processor '../core/host/functions-flexconsumption.bicep' = { - name: '${serviceName}-functions-module' - params: { - name: name - location: location - tags: union(tags, { 'azd-service-name': serviceName }) - identityType: 'UserAssigned' - identityId: identityId - appSettings: union(appSettings, - { - AzureWebJobsStorage__clientId : identityClientId - APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity - }) - applicationInsightsName: applicationInsightsName - appServicePlanId: appServicePlanId - runtimeName: runtimeName - runtimeVersion: runtimeVersion - storageAccountName: storageAccountName - virtualNetworkSubnetId: virtualNetworkSubnetId - instanceMemoryMB: instanceMemoryMB - maximumInstanceCount: maximumInstanceCount - } -} - -output SERVICE_PROCESSOR_NAME string = processor.outputs.name -output SERVICE_API_IDENTITY_PRINCIPAL_ID string = processor.outputs.identityPrincipalId diff --git a/infra/app/rbac.bicep b/infra/app/rbac.bicep new file mode 100644 index 0000000..6a2dde7 --- /dev/null +++ b/infra/app/rbac.bicep @@ -0,0 +1,110 @@ +param storageAccountName string +param appInsightsName string +param managedIdentityPrincipalId string // Principal ID for the Managed Identity +param userIdentityPrincipalId string = '' // Principal ID for the User Identity +param allowUserIdentityPrincipal bool = false // Flag to enable user identity role assignments +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false + +// Define Role Definition IDs internally +var storageRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' //Storage Blob Data Owner role +var queueRoleDefinitionId = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' // Storage Queue Data Contributor role +var tableRoleDefinitionId = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' // Storage Table Data Contributor role +var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID + +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: appInsightsName +} + +// Role assignment for Storage Account (Blob) - Managed Identity +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob) { + name: guid(storageAccount.id, managedIdentityPrincipalId, storageRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Blob) - User Identity +resource storageRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, storageRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Storage Account (Queue) - Managed Identity +resource queueRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue) { + name: guid(storageAccount.id, managedIdentityPrincipalId, queueRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Queue) - User Identity +resource queueRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, queueRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Storage Account (Table) - Managed Identity +resource tableRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable) { + name: guid(storageAccount.id, managedIdentityPrincipalId, tableRoleDefinitionId) // Use managed identity ID + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Storage Account (Table) - User Identity +resource tableRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(storageAccount.id, userIdentityPrincipalId, tableRoleDefinitionId) + scope: storageAccount + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} + +// Role assignment for Application Insights - Managed Identity +resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(applicationInsights.id, managedIdentityPrincipalId, monitoringRoleDefinitionId) // Use managed identity ID + scope: applicationInsights + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) + principalId: managedIdentityPrincipalId // Use managed identity ID + principalType: 'ServicePrincipal' // Managed Identity is a Service Principal + } +} + +// Role assignment for Application Insights - User Identity +resource appInsightsRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { + name: guid(applicationInsights.id, userIdentityPrincipalId, monitoringRoleDefinitionId) + scope: applicationInsights + properties: { + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) + principalId: userIdentityPrincipalId // Use user identity ID + principalType: 'User' // User Identity is a User Principal + } +} diff --git a/infra/app/storage-Access.bicep b/infra/app/storage-Access.bicep deleted file mode 100644 index 9d7b7ec..0000000 --- a/infra/app/storage-Access.bicep +++ /dev/null @@ -1,20 +0,0 @@ -param principalID string -param roleDefinitionID string -param storageAccountName string - -resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { - name: storageAccountName -} - -// Allow access from API to storage account using a managed identity and least priv Storage roles -resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(storageAccount.id, principalID, roleDefinitionID) - scope: storageAccount - properties: { - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) - principalId: principalID - principalType: 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal - } -} - -output ROLE_ASSIGNMENT_NAME string = storageRoleAssignment.name diff --git a/infra/app/storage-PrivateEndpoint.bicep b/infra/app/storage-PrivateEndpoint.bicep index d9ebcc8..b592747 100644 --- a/infra/app/storage-PrivateEndpoint.bicep +++ b/infra/app/storage-PrivateEndpoint.bicep @@ -1,19 +1,13 @@ -// Parameters -@description('Specifies the name of the virtual network.') param virtualNetworkName string - -@description('Specifies the name of the subnet which contains the virtual machine.') param subnetName string - -@description('Specifies the resource name of the Storage resource with an endpoint.') +@description('Specifies the storage account resource name') param resourceName string - -@description('Specifies the location.') param location string = resourceGroup().location - param tags object = {} +param enableBlob bool = true +param enableQueue bool = false +param enableTable bool = false -// Virtual Network resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' existing = { name: virtualNetworkName } @@ -22,66 +16,162 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing name: resourceName } -var blobPrivateDNSZoneName = format('privatelink.blob.{0}', environment().suffixes.storage) - -// Private DNS Zones -resource blobPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: blobPrivateDNSZoneName - location: 'global' - tags: tags - properties: {} - dependsOn: [ - vnet - ] -} +// Storage DNS zone names +var blobPrivateDNSZoneName = 'privatelink.blob.${environment().suffixes.storage}' +var queuePrivateDNSZoneName = 'privatelink.queue.${environment().suffixes.storage}' +var tablePrivateDNSZoneName = 'privatelink.table.${environment().suffixes.storage}' -// Virtual Network Links -resource blobPrivateDnsZoneVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: blobPrivateDnsZone - name: 'link_to_${toLower(virtualNetworkName)}' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id +// AVM module for Blob Private Endpoint with private DNS zone +module blobPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableBlob) { + name: 'blob-private-endpoint-deployment' + params: { + name: 'blob-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' + privateLinkServiceConnections: [ + { + name: 'blobPrivateLinkConnection' + properties: { + privateLinkServiceId: storageAccount.id + groupIds: [ + 'blob' + ] + } + } + ] + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'blobPrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageBlobARecord' + privateDnsZoneResourceId: enableBlob ? privateDnsZoneBlobDeployment.outputs.resourceId : '' + } + ] } } } -// Private Endpoints -resource blobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-08-01' = { - name: 'blob-PrivateEndpoint' - location: location - tags: tags - properties: { +// AVM module for Queue Private Endpoint with private DNS zone +module queuePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableQueue) { + name: 'queue-private-endpoint-deployment' + params: { + name: 'queue-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' privateLinkServiceConnections: [ { - name: 'blobPrivateEndpointConnection' + name: 'queuePrivateLinkConnection' properties: { privateLinkServiceId: storageAccount.id groupIds: [ - 'blob' + 'queue' ] } } ] - subnet: { - id: '${vnet.id}/subnets/${subnetName}' + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'queuePrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageQueueARecord' + privateDnsZoneResourceId: enableQueue ? privateDnsZoneQueueDeployment.outputs.resourceId : '' + } + ] } } } -resource blobPrivateDnsZoneGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2022-01-01' = { - parent: blobPrivateEndpoint - name: 'sbPrivateDnsZoneGroup' - properties: { - privateDnsZoneConfigs: [ +// AVM module for Table Private Endpoint with private DNS zone +module tablePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableTable) { + name: 'table-private-endpoint-deployment' + params: { + name: 'table-private-endpoint' + location: location + tags: tags + subnetResourceId: '${vnet.id}/subnets/${subnetName}' + privateLinkServiceConnections: [ { - name: 'storageBlobARecord' + name: 'tablePrivateLinkConnection' properties: { - privateDnsZoneId: blobPrivateDnsZone.id + privateLinkServiceId: storageAccount.id + groupIds: [ + 'table' + ] + } + } + ] + customDnsConfigs: [] + // Creates private DNS zone and links + privateDnsZoneGroup: { + name: 'tablePrivateDnsZoneGroup' + privateDnsZoneGroupConfigs: [ + { + name: 'storageTableARecord' + privateDnsZoneResourceId: enableTable ? privateDnsZoneTableDeployment.outputs.resourceId : '' } + ] + } + } +} + +// AVM module for Blob Private DNS Zone +module privateDnsZoneBlobDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableBlob) { + name: 'blob-private-dns-zone-deployment' + params: { + name: blobPrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ + { + name: '${resourceName}-blob-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags + } + ] + } +} + +// AVM module for Queue Private DNS Zone +module privateDnsZoneQueueDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableQueue) { + name: 'queue-private-dns-zone-deployment' + params: { + name: queuePrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ + { + name: '${resourceName}-queue-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags + } + ] + } +} + +// AVM module for Table Private DNS Zone +module privateDnsZoneTableDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableTable) { + name: 'table-private-dns-zone-deployment' + params: { + name: tablePrivateDNSZoneName + location: 'global' + tags: tags + virtualNetworkLinks: [ + { + name: '${resourceName}-table-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' + virtualNetworkResourceId: vnet.id + registrationEnabled: false + location: 'global' + tags: tags } ] } diff --git a/infra/app/vnet.bicep b/infra/app/vnet.bicep index 5457465..6b75848 100644 --- a/infra/app/vnet.bicep +++ b/infra/app/vnet.bicep @@ -12,64 +12,37 @@ param appSubnetName string = 'app' param tags object = {} -resource virtualNetwork 'Microsoft.Network/virtualNetworks@2023-05-01' = { - name: vNetName - location: location - tags: tags - properties: { - addressSpace: { - addressPrefixes: [ - '10.0.0.0/16' - ] - } - encryption: { - enabled: false - enforcement: 'AllowUnencrypted' - } +// Migrated to use AVM module instead of direct resource declaration +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { + name: 'vnet-deployment' + params: { + // Required parameters + name: vNetName + addressPrefixes: [ + '10.0.0.0/16' + ] + // Non-required parameters + location: location + tags: tags subnets: [ { name: peSubnetName - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'private-endpoints-subnet') - properties: { - addressPrefixes: [ - '10.0.1.0/28' // allows for 11 usable IP addresses - ] - delegations: [] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - type: 'Microsoft.Network/virtualNetworks/subnets' + addressPrefix: '10.0.1.0/24' + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' } { name: appSubnetName - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'app') - properties: { - addressPrefixes: [ - '10.0.2.0/26' // allows for 59 usable IP addresses - ] - delegations: [ - { - name: 'delegation' - id: resourceId('Microsoft.Network/virtualNetworks/subnets/delegations', vNetName, 'app', 'delegation') - properties: { - //Microsoft.App/environments is the correct delegation for Flex Consumption VNet integration - serviceName: 'Microsoft.App/environments' - } - type: 'Microsoft.Network/virtualNetworks/subnets/delegations' - } - ] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - type: 'Microsoft.Network/virtualNetworks/subnets' + addressPrefix: '10.0.2.0/24' + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + delegation: 'Microsoft.App/environments' } ] - virtualNetworkPeerings: [] - enableDdosProtection: false } } -output peSubnetName string = virtualNetwork.properties.subnets[0].name -output peSubnetID string = virtualNetwork.properties.subnets[0].id -output appSubnetName string = virtualNetwork.properties.subnets[1].name -output appSubnetID string = virtualNetwork.properties.subnets[1].id +output peSubnetName string = peSubnetName +output peSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${peSubnetName}' +output appSubnetName string = appSubnetName +output appSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${appSubnetName}' diff --git a/infra/core/host/appserviceplan.bicep b/infra/core/host/appserviceplan.bicep deleted file mode 100644 index 9ab72a8..0000000 --- a/infra/core/host/appserviceplan.bicep +++ /dev/null @@ -1,20 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param kind string = '' -param reserved bool = true -param sku object - -resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { - name: name - location: location - tags: tags - sku: sku - kind: kind - properties: { - reserved: reserved - } -} - -output id string = appServicePlan.id diff --git a/infra/core/host/functions-flexconsumption.bicep b/infra/core/host/functions-flexconsumption.bicep deleted file mode 100644 index 09c5fa5..0000000 --- a/infra/core/host/functions-flexconsumption.bicep +++ /dev/null @@ -1,86 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -// Reference Properties -param applicationInsightsName string = '' -param appServicePlanId string -param storageAccountName string -param virtualNetworkSubnetId string = '' -@allowed(['SystemAssigned', 'UserAssigned']) -param identityType string -@description('User assigned identity name') -param identityId string - -// Runtime Properties -@allowed([ - 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' -]) -param runtimeName string -@allowed(['3.10', '3.11', '7.4', '8.0', '10', '11', '17', '20']) -param runtimeVersion string -param kind string = 'functionapp,linux' - -// Microsoft.Web/sites/config -param appSettings object = {} -param instanceMemoryMB int = 2048 -param maximumInstanceCount int = 100 - -resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { - name: storageAccountName -} - -resource functions 'Microsoft.Web/sites@2023-12-01' = { - name: name - location: location - tags: tags - kind: kind - identity: { - type: identityType - userAssignedIdentities: { - '${identityId}': {} - } - } - properties: { - serverFarmId: appServicePlanId - functionAppConfig: { - deployment: { - storage: { - type: 'blobContainer' - value: '${stg.properties.primaryEndpoints.blob}deploymentpackage' - authentication: { - type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' - userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' - } - } - } - scaleAndConcurrency: { - instanceMemoryMB: instanceMemoryMB - maximumInstanceCount: maximumInstanceCount - } - runtime: { - name: runtimeName - version: runtimeVersion - } - } - virtualNetworkSubnetId: virtualNetworkSubnetId - } - - resource configAppSettings 'config' = { - name: 'appsettings' - properties: union(appSettings, - { - AzureWebJobsStorage__accountName: stg.name - AzureWebJobsStorage__credential : 'managedidentity' - APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString - }) - } -} - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { - name: applicationInsightsName -} - -output name string = functions.name -output uri string = 'https://${functions.properties.defaultHostName}' -output identityPrincipalId string = identityType == 'SystemAssigned' ? functions.identity.principalId : '' diff --git a/infra/core/identity/userAssignedIdentity.bicep b/infra/core/identity/userAssignedIdentity.bicep deleted file mode 100644 index 0d4e02e..0000000 --- a/infra/core/identity/userAssignedIdentity.bicep +++ /dev/null @@ -1,14 +0,0 @@ -param identityName string -param location string -param tags object = {} - -resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = { - name: identityName - location: location - tags: tags -} - -output identityId string = userAssignedIdentity.id -output identityName string = userAssignedIdentity.name -output identityPrincipalId string = userAssignedIdentity.properties.principalId -output identityClientId string = userAssignedIdentity.properties.clientId diff --git a/infra/core/monitor/appinsights-access.bicep b/infra/core/monitor/appinsights-access.bicep deleted file mode 100644 index f151b10..0000000 --- a/infra/core/monitor/appinsights-access.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param principalID string -param roleDefinitionID string -param appInsightsName string - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { - name: appInsightsName -} - -// Allow access from API to app insights using a managed identity and least priv role -resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(applicationInsights.id, principalID, roleDefinitionID) - scope: applicationInsights - properties: { - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) - principalId: principalID - principalType: 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal - } -} - -output ROLE_ASSIGNMENT_NAME string = appInsightsRoleAssignment.name - diff --git a/infra/core/monitor/applicationinsights.bicep b/infra/core/monitor/applicationinsights.bicep deleted file mode 100644 index f6d9ee5..0000000 --- a/infra/core/monitor/applicationinsights.bicep +++ /dev/null @@ -1,22 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param logAnalyticsWorkspaceId string -param disableLocalAuth bool = false - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { - name: name - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: logAnalyticsWorkspaceId - DisableLocalAuth: disableLocalAuth - } -} - -output connectionString string = applicationInsights.properties.ConnectionString -output instrumentationKey string = applicationInsights.properties.InstrumentationKey -output name string = applicationInsights.name diff --git a/infra/core/monitor/loganalytics.bicep b/infra/core/monitor/loganalytics.bicep deleted file mode 100644 index 770544c..0000000 --- a/infra/core/monitor/loganalytics.bicep +++ /dev/null @@ -1,21 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { - name: name - location: location - tags: tags - properties: any({ - retentionInDays: 30 - features: { - searchVersion: 1 - } - sku: { - name: 'PerGB2018' - } - }) -} - -output id string = logAnalytics.id -output name string = logAnalytics.name diff --git a/infra/core/monitor/monitoring.bicep b/infra/core/monitor/monitoring.bicep deleted file mode 100644 index 791c5eb..0000000 --- a/infra/core/monitor/monitoring.bicep +++ /dev/null @@ -1,31 +0,0 @@ -param logAnalyticsName string -param applicationInsightsName string -param location string = resourceGroup().location -param tags object = {} -param disableLocalAuth bool = false - -module logAnalytics 'loganalytics.bicep' = { - name: 'loganalytics' - params: { - name: logAnalyticsName - location: location - tags: tags - } -} - -module applicationInsights 'applicationinsights.bicep' = { - name: 'applicationinsights' - params: { - name: applicationInsightsName - location: location - tags: tags - logAnalyticsWorkspaceId: logAnalytics.outputs.id - disableLocalAuth: disableLocalAuth - } -} - -output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString -output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey -output applicationInsightsName string = applicationInsights.outputs.name -output logAnalyticsWorkspaceId string = logAnalytics.outputs.id -output logAnalyticsWorkspaceName string = logAnalytics.outputs.name diff --git a/infra/core/storage/storage-account.bicep b/infra/core/storage/storage-account.bicep deleted file mode 100644 index 3826c79..0000000 --- a/infra/core/storage/storage-account.bicep +++ /dev/null @@ -1,42 +0,0 @@ -param name string -param location string = resourceGroup().location -param tags object = {} - -param allowBlobPublicAccess bool = false -@allowed(['Enabled', 'Disabled']) -param publicNetworkAccess string = 'Enabled' -param containers array = [] -param kind string = 'StorageV2' -param minimumTlsVersion string = 'TLS1_2' -param sku object = { name: 'Standard_LRS' } - -resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { - name: name - location: location - tags: tags - kind: kind - sku: sku - properties: { - minimumTlsVersion: minimumTlsVersion - allowBlobPublicAccess: allowBlobPublicAccess - publicNetworkAccess: publicNetworkAccess - allowSharedKeyAccess: false - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - } - - resource blobServices 'blobServices' = if (!empty(containers)) { - name: 'default' - resource container 'containers' = [for container in containers: { - name: container.name - properties: { - publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' - } - }] - } -} - -output name string = storage.name -output primaryEndpoints object = storage.properties.primaryEndpoints diff --git a/infra/main.bicep b/infra/main.bicep index 3321124..e512b18 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -6,28 +6,64 @@ targetScope = 'subscription' param environmentName string @minLength(1) -@description('Primary location for all resources') -@allowed(['australiaeast', 'eastasia', 'eastus', 'eastus2', 'northeurope', 'southcentralus', 'southeastasia', 'swedencentral', 'uksouth', 'westus2', 'eastus2euap']) +@description('Primary location for all resources & Flex Consumption Function App') +@allowed([ + 'australiaeast' + 'australiasoutheast' + 'brazilsouth' + 'canadacentral' + 'centralindia' + 'centralus' + 'eastasia' + 'eastus' + 'eastus2' + 'eastus2euap' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'northeurope' + 'norwayeast' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'uaenorth' + 'uksouth' + 'ukwest' + 'westcentralus' + 'westeurope' + 'westus' + 'westus2' + 'westus3' +]) @metadata({ azd: { type: 'location' } }) param location string - -param processorServiceName string = '' -param processorUserAssignedIdentityName string = '' +param vnetEnabled bool +param apiServiceName string = '' +param apiUserAssignedIdentityName string = '' param applicationInsightsName string = '' param appServicePlanName string = '' param logAnalyticsName string = '' param resourceGroupName string = '' param storageAccountName string = '' param vNetName string = '' -param disableLocalAuth bool = true +@description('Id of the user identity to be used for testing and debugging. This is not required in production. Leave empty if not needed.') +param principalId string = deployer().objectId var abbrs = loadJsonContent('./abbreviations.json') var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) var tags = { 'azd-env-name': environmentName } +var functionAppName = !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesFunctions}api-${resourceToken}' +var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, resourceToken)), 7)}' // Organize resources in a resource group resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { @@ -36,80 +72,111 @@ resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { tags: tags } -// User assigned managed identity to be used by the Function App to reach storage and service bus -module processorUserAssignedIdentity './core/identity/userAssignedIdentity.bicep' = { - name: 'processorUserAssignedIdentity' +// User assigned managed identity to be used by the function app to reach storage and other dependencies +// Assign specific roles to this identity in the RBAC module +module apiUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: 'apiUserAssignedIdentity' scope: rg params: { location: location tags: tags - identityName: !empty(processorUserAssignedIdentityName) ? processorUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}processor-${resourceToken}' + name: !empty(apiUserAssignedIdentityName) ? apiUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' } } -// The application backend -module processor './app/processor.bicep' = { - name: 'processor' +// Create an App Service Plan to group applications under the same payment plan and SKU +module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.1' = { + name: 'appserviceplan' scope: rg params: { - name: !empty(processorServiceName) ? processorServiceName : '${abbrs.webSitesFunctions}processor-${resourceToken}' + name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' + sku: { + name: 'FC1' + tier: 'FlexConsumption' + } + reserved: true location: location tags: tags - applicationInsightsName: monitoring.outputs.applicationInsightsName - appServicePlanId: appServicePlan.outputs.id + } +} + +module api './app/api.bicep' = { + name: 'api' + scope: rg + params: { + name: functionAppName + location: location + tags: tags + applicationInsightsName: monitoring.outputs.name + appServicePlanId: appServicePlan.outputs.resourceId runtimeName: 'node' - runtimeVersion: '20' + runtimeVersion: '22' storageAccountName: storage.outputs.name - identityId: processorUserAssignedIdentity.outputs.identityId - identityClientId: processorUserAssignedIdentity.outputs.identityClientId + enableBlob: storageEndpointConfig.enableBlob + enableQueue: storageEndpointConfig.enableQueue + enableTable: storageEndpointConfig.enableTable + deploymentStorageContainerName: deploymentStorageContainerName + identityId: apiUserAssignedIdentity.outputs.resourceId + identityClientId: apiUserAssignedIdentity.outputs.clientId appSettings: { } - virtualNetworkSubnetId: serviceVirtualNetwork.outputs.appSubnetID + virtualNetworkSubnetId: vnetEnabled ? serviceVirtualNetwork.outputs.appSubnetID : '' } } -// Backing storage for Azure functions processor -module storage './core/storage/storage-account.bicep' = { +// Backing storage for Azure functions backend API +module storage 'br/public:avm/res/storage/storage-account:0.8.3' = { name: 'storage' scope: rg params: { name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' + allowBlobPublicAccess: false + allowSharedKeyAccess: false // Disable local authentication methods as per policy + dnsEndpointType: 'Standard' + publicNetworkAccess: vnetEnabled ? 'Disabled' : 'Enabled' + networkAcls: vnetEnabled ? { + defaultAction: 'Deny' + bypass: 'None' + } : { + defaultAction: 'Allow' + bypass: 'AzureServices' + } + blobServices: { + containers: [{name: deploymentStorageContainerName}] + } + minimumTlsVersion: 'TLS1_2' // Enforcing TLS 1.2 for better security location: location tags: tags - containers: [{name: 'deploymentpackage'}] - publicNetworkAccess: 'Disabled' } } -var storageRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' //Storage Blob Data Owner role - -// Allow access from processor to storage account using a managed identity -module storageRoleAssignmentApi 'app/storage-Access.bicep' = { - name: 'storageRoleAssignmentPRocessor' - scope: rg - params: { - storageAccountName: storage.outputs.name - roleDefinitionID: storageRoleDefinitionId - principalID: processorUserAssignedIdentity.outputs.identityPrincipalId - } +// Define the configuration object locally to pass to the modules +var storageEndpointConfig = { + enableBlob: true // Required for AzureWebJobsStorage, .zip deployment, Event Hubs trigger and Timer trigger checkpointing + enableQueue: false // Required for Durable Functions and MCP trigger + enableTable: false // Required for Durable Functions and OpenAI triggers and bindings + enableFiles: false // Not required, used in legacy scenarios + allowUserIdentityPrincipal: true // Allow interactive user identity to access for testing and debugging } -module appServicePlan './core/host/appserviceplan.bicep' = { - name: 'appserviceplan' +// Consolidated Role Assignments +module rbac 'app/rbac.bicep' = { + name: 'rbacAssignments' scope: rg params: { - name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' - location: location - tags: tags - sku: { - name: 'FC1' - tier: 'FlexConsumption' - } + storageAccountName: storage.outputs.name + appInsightsName: monitoring.outputs.name + managedIdentityPrincipalId: apiUserAssignedIdentity.outputs.principalId + userIdentityPrincipalId: principalId + enableBlob: storageEndpointConfig.enableBlob + enableQueue: storageEndpointConfig.enableQueue + enableTable: storageEndpointConfig.enableTable + allowUserIdentityPrincipal: storageEndpointConfig.allowUserIdentityPrincipal } } -// Virtual Network & private endpoint -module serviceVirtualNetwork 'app/vnet.bicep' = { +// Virtual Network & private endpoint to blob storage +module serviceVirtualNetwork 'app/vnet.bicep' = if (vnetEnabled) { name: 'serviceVirtualNetwork' scope: rg params: { @@ -119,47 +186,48 @@ module serviceVirtualNetwork 'app/vnet.bicep' = { } } -module servicePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = { +module storagePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = if (vnetEnabled) { name: 'servicePrivateEndpoint' scope: rg params: { location: location tags: tags virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' - subnetName: serviceVirtualNetwork.outputs.peSubnetName + subnetName: vnetEnabled ? serviceVirtualNetwork.outputs.peSubnetName : '' // Keep conditional check for safety, though module won't run if !vnetEnabled resourceName: storage.outputs.name + enableBlob: storageEndpointConfig.enableBlob + enableQueue: storageEndpointConfig.enableQueue + enableTable: storageEndpointConfig.enableTable } } -// Monitor application with Azure Monitor -module monitoring './core/monitor/monitoring.bicep' = { - name: 'monitoring' +// Monitor application with Azure Monitor - Log Analytics and Application Insights +module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.11.1' = { + name: '${uniqueString(deployment().name, location)}-loganalytics' scope: rg params: { + name: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' location: location tags: tags - logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' - applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' - disableLocalAuth: disableLocalAuth + dataRetention: 30 } } - -var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID - -// Allow access from processor to application insights using a managed identity -module appInsightsRoleAssignmentApi './core/monitor/appinsights-access.bicep' = { - name: 'appInsightsRoleAssignmentPRocessor' + +module monitoring 'br/public:avm/res/insights/component:0.6.0' = { + name: '${uniqueString(deployment().name, location)}-appinsights' scope: rg params: { - appInsightsName: monitoring.outputs.applicationInsightsName - roleDefinitionID: monitoringRoleDefinitionId - principalID: processorUserAssignedIdentity.outputs.identityPrincipalId + name: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' + location: location + tags: tags + workspaceResourceId: logAnalytics.outputs.resourceId + disableLocalAuth: true } } // App outputs -output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString +output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.connectionString output AZURE_LOCATION string = location output AZURE_TENANT_ID string = tenant().tenantId -output SERVICE_PROCESSOR_NAME string = processor.outputs.SERVICE_PROCESSOR_NAME -output AZURE_FUNCTION_NAME string = processor.outputs.SERVICE_PROCESSOR_NAME +output SERVICE_API_NAME string = api.outputs.SERVICE_API_NAME +output AZURE_FUNCTION_NAME string = api.outputs.SERVICE_API_NAME diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 8f7787b..dd97cbd 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -7,6 +7,12 @@ }, "location": { "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "vnetEnabled": { + "value": "${VNET_ENABLED}" } } } \ No newline at end of file