Microsoft tools

Locking Down PaaS: Private Link, Managed Identity, and Firewall Patterns that Scale

Goal: secure by default patterns for App Service, Storage, SQL, Key Vault, and Cosmos DB.

Locking Down PaaS: Private Link, Managed Identity, and Firewall Patterns that Scale. Microsoft tools article banner on grbadhon.com

Goal: secure by default patterns for App Service, Storage, SQL, Key Vault, and Cosmos DB.

Reference architecture

code
Internet
                     |
                Azure Front Door (optional)
                     |
           +---------App Service (WebApp)---------+
           |           inbound via Private EP      |
           |           outbound via VNet integ     |
           +--------------------+------------------+
                                |
                           Spoke VNet
                     (subnets: app, pe, integ)
                                |
                +---------------+----------------+
                |                                |
          Private Endpoint                    Private Endpoint
          to Storage (blob)                   to Key Vault
                |                                |
         privatelink.blob.core         privatelink.vaultcore.azure.net
          .windows.net via hub         via hub Private DNS Zones

Hub VNet
  - Firewall or NVA (egress control)
  - Private DNS Zones linked once per service
  - DNS forwarders to 168.63.129.16

Spokes
  - VNet pe subnet for PE NICs
  - App subnet for runtime
  - Integration subnet for regional VNet integration

Pattern library

Inbound to PaaS

code
Client -> Private DNS resolves privatelink FQDN -> PE NIC in pe subnet -> service
Notes: block public network access on target service. Require approved PE connection.

Outbound from App Service

code
WebApp -> VNet Integration (integ subnet) -> UDR to Azure Firewall -> allowlist to:
  - Private Endpoint NICs for Storage, SQL, Key Vault, Cosmos
  - Platform endpoints for identity, monitoring
Block all else. No direct Internet egress.

Hub and spoke Private DNS

json
[Private DNS Zones in Hub]
  privatelink.blob.core.windows.net
  privatelink.vaultcore.azure.net
  privatelink.database.windows.net
  privatelink.documents.azure.com
Link to Hub VNet only. Spokes resolve via Hub.

Bicep snippets

yaml
param hubVnetId string

resource dnsBlob 'Microsoft.Network/privateDnsZones@2018-09-01' = {
  name: 'privatelink.blob.core.windows.net'
  location: 'global'
}
resource dnsKv 'Microsoft.Network/privateDnsZones@2018-09-01' = {
  name: 'privatelink.vaultcore.azure.net'
  location: 'global'
}
resource dnsSql 'Microsoft.Network/privateDnsZones@2018-09-01' = {
  name: 'privatelink.database.windows.net'
  location: 'global'
}
resource dnsCosmos 'Microsoft.Network/privateDnsZones@2018-09-01' = {
  name: 'privatelink.documents.azure.com'
  location: 'global'
}

@batchSize(1)
module linkAll './modules/privateDnsLink.bicep' = [for z in [dnsBlob, dnsKv, dnsSql, dnsCosmos]: {
  name: 'link-${z.name}'
  params: {
    zoneId: z.id
    vnetId: hubVnetId
  }
}]

modules/privateDnsLink.bicep:

yaml
param zoneId string
param vnetId string

resource link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2018-09-01' = {
  name: 'hub-link'
  scope: resourceGroup()
  parent: {
    id: zoneId
  }
  location: 'global'
  properties: {
    registrationEnabled: false
    virtualNetwork: { id: vnetId }
  }
}

Private Endpoints for data services (Spoke)

yaml
param location string
param peSubnetId string
param storageId string
param keyVaultId string

resource peStorage 'Microsoft.Network/privateEndpoints@2023-09-01' = {
  name: 'pe-storage-blob'
  location: location
  properties: {
    subnet: { id: peSubnetId }
    privateLinkServiceConnections: [
      {
        name: 'blob'
        properties: {
          groupIds: ['blob']
          privateLinkServiceId: storageId
        }
      }
    ]
  }
}
resource peStorageDns 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-03-01' = {
  name: 'dns'
  parent: peStorage
  properties: {
    privateDnsZoneConfigs: [
      {
        name: 'blob'
        properties: { privateDnsZoneId: resourceId('Microsoft.Network/privateDnsZones','privatelink.blob.core.windows.net') }
      }
    ]
  }
}

resource peKv 'Microsoft.Network/privateEndpoints@2023-09-01' = {
  name: 'pe-kv'
  location: location
  properties: {
    subnet: { id: peSubnetId }
    privateLinkServiceConnections: [
      {
        name: 'vault'
        properties: {
          groupIds: ['vault']
          privateLinkServiceId: keyVaultId
        }
      }
    ]
  }
}
resource peKvDns 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-03-01' = {
  name: 'dns'
  parent: peKv
  properties: {
    privateDnsZoneConfigs: [
      {
        name: 'vault'
        properties: { privateDnsZoneId: resourceId('Microsoft.Network/privateDnsZones','privatelink.vaultcore.azure.net') }
      }
    ]
  }
}

Firewall and public access settings

yaml
param storageName string
param kvName string

resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' existing = {
  name: storageName
}
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
  name: kvName
}

resource stgNet 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageName
  location: stg.location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: {
    publicNetworkAccess: 'Disabled'
    minimumTlsVersion: 'TLS1_2'
  }
}

resource kvNet 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: kvName
  location: kv.location
  properties: {
    tenantId: subscription().tenantId
    sku: { family: 'A', name: 'standard' }
    enableRbacAuthorization: true
    publicNetworkAccess: 'Disabled'
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'None'
    }
  }
}

End to end IaC example

Small but complete workload. One Web App with system assigned identity, VNet integration, PE to Storage and Key Vault, RBAC for identity.

yaml
param location string = resourceGroup().location
param spokeIntegSubnetId string
param peSubnetId string

// App Service Plan and WebApp with system assigned MI
resource plan 'Microsoft.Web/serverfarms@2022-09-01' = {
  name: 'asp-secure'
  location: location
  sku: { name: 'P1v3', capacity: 1, tier: 'PremiumV3' }
}
resource web 'Microsoft.Web/sites@2023-12-01' = {
  name: 'web-secure'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    siteConfig: {
      vnetRouteAllEnabled: true
      ftpsState: 'Disabled'
      ipSecurityRestrictionsDefaultAction: 'Deny'
    }
    publicNetworkAccess: 'Disabled'
  }
}
// VNet integration
resource integ 'Microsoft.Web/sites/virtualNetworkConnections@2023-12-01' = {
  name: 'vnet'
  parent: web
  properties: {
    subnetResourceId: spokeIntegSubnetId
    isSwift: true
  }
}

// Data services
resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: 'stgsecure${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: {
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
    publicNetworkAccess: 'Disabled'
  }
}
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: 'kv-secure'
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: { name: 'standard', family: 'A' }
    enableRbacAuthorization: true
    publicNetworkAccess: 'Disabled'
    networkAcls: { defaultAction: 'Deny', bypass: 'None' }
  }
}
// Private Endpoints
module pe './pe.bicep' = {
  name: 'pe-all'
  params: {
    location: location
    peSubnetId: peSubnetId
    storageId: stg.id
    keyVaultId: kv.id
  }
}
// RBAC for Managed Identity
var miPrincipalId = web.identity.principalId
resource roleKv 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(kv.id, miPrincipalId, 'kv-secrets-user')
  scope: kv
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions','4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
    principalId: miPrincipalId
    principalType: 'ServicePrincipal'
  }
}
resource roleBlob 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(stg.id, miPrincipalId, 'stg-blob-data-contributor')
  scope: stg
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions','ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor
    principalId: miPrincipalId
    principalType: 'ServicePrincipal'
  }
}

pe.bicep is the snippet shown earlier.

Managed Identity patterns and code

When to use system assigned

  • One to one relationship with the app
  • Lifecycle tied to the app, no key rollover to manage

When to use user assigned

  • Identity reused by many apps
  • You want to rotate RBAC once, update many consumers

.NET examples using DefaultAzureCredential

Key Vault secret read:

csharp
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var kvUrl = new Uri("https://kv-secure.vault.azure.net/");
var cred = new DefaultAzureCredential(); // set AZURE_CLIENT_ID for user assigned
var client = new SecretClient(kvUrl, cred);
var secret = await client.GetSecretAsync("DbPassword");

Blob write with MI:

csharp
using Azure.Identity;
using Azure.Storage.Blobs;
var blob = new BlobClient(new Uri("https://stgsecure.blob.core.windows.net/app/log.txt"), new DefaultAzureCredential());
await blob.UploadAsync(BinaryData.FromString("hello"), overwrite: true);

SQL access token:

csharp
using Azure.Core;
using Azure.Identity;
using Microsoft.Data.SqlClient;
var token = await new DefaultAzureCredential().GetTokenAsync(new TokenRequestContext(new[]{"https://database.windows.net/.default"}));
using var conn = new SqlConnection("Server=tcp:myserver.database.windows.net,1433;Database=mydb;Encrypt=True");
conn.AccessToken = token.Token;
await conn.OpenAsync();

User assigned MI in code:

code
var cred = new DefaultAzureCredential(new DefaultAzureCredentialOptions{ ManagedIdentityClientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") });

SQL contained user for the MI, run as Microsoft Entra ID admin:

code
CREATE USER [web-secure] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [web-secure];
ALTER ROLE db_datawriter ADD MEMBER [web-secure];

Break glass and troubleshooting

Break glass runbook

code
1. Pager triggers. Incident commander approves elevation.
2. Elevate via PIM to built in roles with least privilege.
3. Create time bound firewall exception or temp PE in pe subnet.
4. Audit everything. Remove access when finished. File post incident review.

Troubleshooting flowchart

json
[Client cannot reach app]
      |
      v
[nslookup privatelink FQDN ok?] --no--> [Fix DNS: zone linked to hub? record exists?]
      |
     yes
      |
      v
[PE connection approved?] --no--> [Approve PE on resource]
      |
     yes
      |
      v
[Firewall set to Deny public?] --no--> [Disable public, rely on PE]
      |
     yes
      |
      v
[App outbound blocked?]
      |
      +--> [Check UDR to Firewall, allow tags for AzureMonitor, AzureActiveDirectory]
      |
      v
[MI call failing]
      |
      +--> [Check role assignments, token from IMDS, tenant trust]

Common questions

Work down the chain. Check that the privatelink name resolves, which usually means the Private DNS zone is linked to the hub and the record exists. Then confirm the Private Endpoint connection is approved on the target resource, that public network access is set to Disabled, and that outbound rules and the route to the firewall are not blocking the call.

A system assigned identity has a one to one relationship with the application and its lifecycle is tied to that application, with no key rollover to manage. A user assigned identity is reusable across many applications, so RBAC is granted once and many consumers inherit it. In code, set AZURE_CLIENT_ID or ManagedIdentityClientId when using a user assigned identity.

Key Vault Secrets User on the vault and Storage Blob Data Contributor on the storage account, assigned to the application managed identity with a principal type of ServicePrincipal. Enable RBAC authorisation on the vault rather than access policies. For Azure SQL, sign in as the Microsoft Entra ID administrator and create a contained user from the external provider, then add it to the database roles.

In the hub, linked once per service to the hub virtual network, with spokes resolving through the hub. The zones needed for this pattern are privatelink.blob.core.windows.net, privatelink.vaultcore.azure.net, privatelink.database.windows.net and privatelink.documents.azure.com. Private Endpoint NICs live in a dedicated pe subnet in the spoke, with a private DNS zone group attached to each endpoint.

Set publicNetworkAccess to Disabled on both resources, set allowBlobPublicAccess to false and minimumTlsVersion to TLS1_2 on the storage account, and set the Key Vault network ACLs to a default action of Deny with bypass set to None. On the web app, use httpsOnly, disable FTPS and set the IP restrictions default action to Deny.

Through a documented break glass runbook rather than a permanent exception. An incident commander approves the elevation, the engineer elevates through Privileged Identity Management to a built in role with least privilege, and a time bound firewall exception or temporary Private Endpoint is created in the pe subnet. Audit everything, remove the access afterwards and hold a post incident review.