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
202 Accepted
The email is sent as contact@quidne.com and the request is recorded.
Function App
The request, hop by hop
- 1 Load the contact page Visitor's browser → Static Web App None: public HTTPS Needs no role assignment Passed
- 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 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 The platform resolves Turnstile__Secret Function App → Key Vault Key Vault reference, system-assigned identity Needs
vaultSecretsUser: Key Vault Secrets User Passed - 5 Send the email Function App → Communication Services DefaultAzureCredential: the managed identity's Entra token Needs
acsSender: Communication and Email Service Owner Passed - 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 Record the request Function App → App Insights Entra token: the component disables local auth Needs
metricsPublisher: Monitoring Metrics Publisher Passed - 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
| Removed | Fails at | The visitor sees |
|---|---|---|
storageBlobOwner Storage Blob Data Owner | Hop 3 | No answer. A cold instance cannot read its package, so the contact code never runs. |
storageQueueContributor Storage Queue Data Contributor | No hop | 202 Accepted. No hop on this request uses a queue, so it completes. |
storageTableContributor Storage Table Data Contributor | No hop | 202 Accepted. No hop on this request uses a table, so it completes. |
vaultSecretsUser Key Vault Secrets User | Hop 4 | 403 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 Owner | Hop 5 | 502 delivery_failed. Communication Services refuses the identity, the send throws, and the visitor is told delivery failed. |
metricsPublisher Monitoring Metrics Publisher | Hop 7 | 202 Accepted. The email is sent, but ingestion refuses the telemetry, so the request never reaches App Insights. |
vaultSecretsOfficer Key Vault Secrets Officer | No hop | 202 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.
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 sharedswa.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.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.
Remove one role, lose the form
Take away
vaultSecretsUserand the platform cannot resolveTurnstile__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.Some failures are silent
Without
metricsPublisherthe 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.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
}
}