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

by G.R Badhon

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

Reference architecture

 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

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

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

[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

Private DNS zones and VNet link (Hub)

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:

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)

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

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.

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:

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:

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:

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:

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

SQL contained user for the MI, run as AAD admin:

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

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

[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] 

You may also like