An Azure app, hop by hop

A map of every Azure resource this site's Bicep declares. Send a contact-form request through it hop by hop and see how each hop authenticates, then remove one role assignment and watch the request fail where it is needed.

Competency
Cloud application engineering
Context
Quidne · Cloud application engineering
Built with
Bicep, Azure Functions, Azure Static Web Apps, Key Vault, Azure Communication Services

Try it

What this site's Bicep declares
Azure Pipelines pipelines/01-site.yml: deploys Quidne.Deploy's swa.bicep, then uploads the content with the Static Web App's deployment token 01-site.yml pipelines/01-site.yml: deploys api.bicep, then publishes the Function's zip package 01-site.yml 1 2 3 4 5 6 7 8 Visitor's browsernot declared Static Web AppstaticSites Function AppFlex Consumption StoragestorageAccounts Key Vaultvaults Communication ServicescommunicationServices Email domainemailServices App Insightscomponents Log Analyticsworkspaces

202 Accepted

The email is sent as contact@quidne.com and the request is recorded.

The request, hop by hop

  1. 1 Load the contact page Visitor's browser → Static Web App None: public HTTPS Needs no role assignment Passed
  2. 2 POST /api/contact Visitor's browser → Function App Anonymous trigger, behind a CORS origin list and a Turnstile token Needs no role assignment Passed
  3. 3 A cold instance reads its package from the deployment container Function App → Storage System-assigned identity, Entra token Needs storageBlobOwner: Storage Blob Data Owner Passed
  4. 4 The platform resolves Turnstile__Secret Function App → Key Vault Key Vault reference, system-assigned identity Needs vaultSecretsUser: Key Vault Secrets User Passed
  5. 5 Send the email Function App → Communication Services DefaultAzureCredential: the managed identity's Entra token Needs acsSender: Communication and Email Service Owner Passed
  6. 6 Send as contact@quidne.com Communication Services → Email domain Linked domain, no token: linkedDomains on the Communication Service Needs no role assignment Passed
  7. 7 Record the request Function App → App Insights Entra token: the component disables local auth Needs metricsPublisher: Monitoring Metrics Publisher Passed
  8. 8 Store the telemetry in the workspace App Insights → Log Analytics Workspace-based component: WorkspaceResourceId Needs no role assignment Passed
All 7 role assignments, and what removing each one does
RemovedFails atThe visitor sees
storageBlobOwner Storage Blob Data OwnerHop 3No answer. A cold instance cannot read its package, so the contact code never runs.
storageQueueContributor Storage Queue Data ContributorNo hop202 Accepted. No hop on this request uses a queue, so it completes.
storageTableContributor Storage Table Data ContributorNo hop202 Accepted. No hop on this request uses a table, so it completes.
vaultSecretsUser Key Vault Secrets UserHop 4403 verification_failed. Turnstile__Secret stays an unresolved Key Vault reference, so Turnstile rejects the check and the form is refused.
acsSender Communication and Email Service OwnerHop 5502 delivery_failed. Communication Services refuses the identity, the send throws, and the visitor is told delivery failed.
metricsPublisher Monitoring Metrics PublisherHop 7202 Accepted. The email is sent, but ingestion refuses the telemetry, so the request never reaches App Insights.
vaultSecretsOfficer Key Vault Secrets OfficerNo hop202 Accepted. It is granted to the person who deploys, not the app, so no hop on this request uses it.

Simulation. The map, the hops and what each failure looks like are our own reading of this site's code; every quoted block is read from deploy/bicep at 590ca09 on 9 October 2026, and the test suite holds each one to that file byte for byte.

  1. Eight boxes, each one declared

    Seven boxes are resource blocks in api.bicep, and every block in that file sits on a box. The Static Web App is declared once for every Quidne site, in Quidne.Deploy's shared swa.bicep. Hover or focus a box to read the Bicep that declares it, word for word. The visitor's browser is the one thing drawn that the Bicep does not declare.

  2. One request, eight hops

    The browser loads the page from the Static Web App and posts the form to the Function App. Four hops after that need a role assignment, and each is granted to the Function App's own system-assigned identity. No storage key or secret value appears in either file: Storage refuses shared keys, App Insights refuses local auth, and the Turnstile secret is a Key Vault reference.

  3. Remove one role, lose the form

    Take away vaultSecretsUser and the platform cannot resolve Turnstile__Secret. The setting keeps its literal Key Vault reference, Turnstile rejects it, and the visitor's form is refused with a 403, several steps away from the role assignment that caused it.

  4. Some failures are silent

    Without metricsPublisher the visitor still gets a 202 and the email still arrives. Only the telemetry is refused, because the component disables local auth, so the request is sent but App Insights has no record of it.

  5. Your turn

    Pick any of the seven role assignments and send a request. Three of them sit on no hop of this request's path; the table under the hops says what each one is for.

What it shows

The Azure resources behind this site's contact form, drawn from the Bicep that deploys them rather than from a diagram someone has to keep up to date. Each hop says how it authenticates, and the role assignments that let the Function App's identity through are the ones the Bicep declares.

What you get

  • An artefact: two Bicep files that declare every resource and role assignment, deployed by the pipeline to DEV, UAT and PROD.
  • A proof: a Pester test that holds every quoted block to the Bicep byte for byte, and every hop's role to a declared assignment with the right scope and principal.
  • A number: 7 role assignments, and no storage key or secret value in either file.

After we leave, the map stays true for as long as the test runs. Change the Bicep and the gate names the box that no longer matches it.

How it is built

tools/export-app-map.ps1 quotes each resource block from deploy/bicep into the page's data and rewrites its static frame. The boxes, the hops and what each failure looks like are written by hand from the Function's code, and the test suite checks them. Each hop's role must be a declared assignment scoped to the box it reaches and granted to the identity it leaves from, and each what-if must fail where the browser's own walk says it does.

The map, the hop list, the result and every Bicep block are plain HTML, readable with scripts off. Each state is carried by a glyph and a word, with colour as reinforcement, and every request is narrated to a live region.

The Bicep behind each box

Static Web App

Microsoft.Web/staticSites · declared once for every Quidne site in Quidne.Deploy's shared bicep/swa.bicep at v1.4.1, so this repository has no block to quote.

Function App

functionApp Microsoft.Web/sites · deploy/bicep/api.bicep:172–256
resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp,linux'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    functionAppConfig: {
      deployment: {
        storage: {
          type: 'blobContainer'
          value: '${storage.properties.primaryEndpoints.blob}${deploymentContainerName}'
          authentication: {
            type: 'SystemAssignedIdentity'
          }
        }
      }
      runtime: {
        name: 'dotnet-isolated'
        version: '10.0'
      }
      scaleAndConcurrency: {
        maximumInstanceCount: maximumInstanceCount
        instanceMemoryMB: 2048
      }
    }
    siteConfig: {
      minTlsVersion: '1.2'
      ftpsState: 'Disabled'
      cors: {
        allowedOrigins: allowedOrigins
        supportCredentials: false
      }
      appSettings: [
        {
          name: 'AzureWebJobsStorage__accountName'
          value: storage.name
        }
        {
          name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
          value: appInsights.properties.ConnectionString
        }
        {
          name: 'APPLICATIONINSIGHTS_AUTHENTICATION_STRING'
          value: 'Authorization=AAD'
        }
        {
          name: 'Turnstile__Secret'
          value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=${turnstileSecretName})'
        }
        {
          name: 'Email__Mode'
          value: 'Acs'
        }
        {
          name: 'Email__Endpoint'
          value: 'https://${communicationService.properties.hostName}'
        }
        {
          name: 'Email__Sender'
          value: senderAddress
        }
        {
          name: 'Email__Recipient'
          value: recipientAddress
        }
        {
          name: 'Contact__Environment'
          value: environment
        }
        {
          name: 'Contact__AllowedHostnames'
          value: join(allowedHostnames, ',')
        }
      ]
    }
  }
  dependsOn: [
    deploymentContainer
    sender
  ]
}
plan Microsoft.Web/serverfarms · deploy/bicep/api.bicep:159–170
resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
  name: planName
  location: location
  kind: 'functionapp'
  sku: {
    tier: 'FlexConsumption'
    name: 'FC1'
  }
  properties: {
    reserved: true
  }
}
scmBasicAuth Microsoft.Web/sites/basicPublishingCredentialsPolicies · deploy/bicep/api.bicep:258–264
resource scmBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = {
  parent: functionApp
  name: 'scm'
  properties: {
    allow: false
  }
}
ftpBasicAuth Microsoft.Web/sites/basicPublishingCredentialsPolicies · deploy/bicep/api.bicep:266–272
resource ftpBasicAuth 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = {
  parent: functionApp
  name: 'ftp'
  properties: {
    allow: false
  }
}

Storage

storage Microsoft.Storage/storageAccounts · deploy/bicep/api.bicep:76–89
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageAccountName
  location: location
  kind: 'StorageV2'
  sku: {
    name: 'Standard_LRS'
  }
  properties: {
    allowSharedKeyAccess: false
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
  }
}
blobService Microsoft.Storage/storageAccounts/blobServices · deploy/bicep/api.bicep:91–94
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
  parent: storage
  name: 'default'
}
deploymentContainer Microsoft.Storage/storageAccounts/blobServices/containers · deploy/bicep/api.bicep:96–102
resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
  parent: blobService
  name: deploymentContainerName
  properties: {
    publicAccess: 'None'
  }
}
storageBlobOwner Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:274–282
resource storageBlobOwner 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: storage
  name: guid(resourceGroup().id, functionApp.id, roles.storageBlobDataOwner)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.storageBlobDataOwner)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}
storageQueueContributor Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:284–292
resource storageQueueContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: storage
  name: guid(resourceGroup().id, functionApp.id, roles.storageQueueDataContributor)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.storageQueueDataContributor)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}
storageTableContributor Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:294–302
resource storageTableContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: storage
  name: guid(resourceGroup().id, functionApp.id, roles.storageTableDataContributor)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.storageTableDataContributor)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Key Vault

keyVault Microsoft.KeyVault/vaults · deploy/bicep/api.bicep:104–119
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: keyVaultName
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: {
      family: 'A'
      name: 'standard'
    }
    enableRbacAuthorization: true
    enableSoftDelete: true
    softDeleteRetentionInDays: 90
    // ARM rejects an explicit false once purge protection exists, and it can never be turned off.
    enablePurgeProtection: enablePurgeProtection ? true : null
  }
}
vaultSecretsUser Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:314–322
resource vaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: keyVault
  name: guid(resourceGroup().id, functionApp.id, roles.keyVaultSecretsUser)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.keyVaultSecretsUser)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}
vaultSecretsOfficer Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:334–342
resource vaultSecretsOfficer 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments && !empty(vaultOfficerPrincipalId)) {
  scope: keyVault
  name: guid(resourceGroup().id, vaultOfficerPrincipalId, roles.keyVaultSecretsOfficer)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.keyVaultSecretsOfficer)
    principalId: vaultOfficerPrincipalId
    principalType: 'User'
  }
}

Communication Services

communicationService Microsoft.Communication/communicationServices · deploy/bicep/api.bicep:148–157
resource communicationService 'Microsoft.Communication/communicationServices@2023-04-01' = {
  name: communicationServiceName
  location: 'global'
  properties: {
    dataLocation: 'United States'
    linkedDomains: linkEmailDomain ? [
      emailDomain.id
    ] : []
  }
}
acsSender Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:324–332
resource acsSender 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: communicationService
  name: guid(resourceGroup().id, functionApp.id, acsSendRoleDefinitionId)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acsSendRoleDefinitionId)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Email domain

emailService Microsoft.Communication/emailServices · deploy/bicep/api.bicep:121–127
resource emailService 'Microsoft.Communication/emailServices@2023-04-01' = {
  name: emailServiceName
  location: 'global'
  properties: {
    dataLocation: 'United States'
  }
}
emailDomain Microsoft.Communication/emailServices/domains · deploy/bicep/api.bicep:129–137
resource emailDomain 'Microsoft.Communication/emailServices/domains@2023-04-01' = {
  parent: emailService
  name: emailDomainName
  location: 'global'
  properties: {
    domainManagement: 'CustomerManaged'
    userEngagementTracking: 'Disabled'
  }
}
sender Microsoft.Communication/emailServices/domains/senderUsernames · deploy/bicep/api.bicep:139–146
resource sender 'Microsoft.Communication/emailServices/domains/senderUsernames@2023-04-01' = {
  parent: emailDomain
  name: senderUsername
  properties: {
    username: senderUsername
    displayName: 'Quidne contact form'
  }
}

App Insights

appInsights Microsoft.Insights/components · deploy/bicep/api.bicep:65–74
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalytics.id
    DisableLocalAuth: true
  }
}
metricsPublisher Microsoft.Authorization/roleAssignments · deploy/bicep/api.bicep:304–312
resource metricsPublisher 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployRoleAssignments) {
  scope: appInsights
  name: guid(resourceGroup().id, functionApp.id, roles.monitoringMetricsPublisher)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.monitoringMetricsPublisher)
    principalId: functionApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Log Analytics

logAnalytics Microsoft.OperationalInsights/workspaces · deploy/bicep/api.bicep:54–63
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: logAnalyticsName
  location: location
  properties: {
    sku: {
      name: 'PerGB2018'
    }
    retentionInDays: 30
  }
}