Automation Accounts, Logic Apps, Function Apps, and CI/CD
This section maps the five major Azure automation platforms to their security properties, authentication requirements, and appropriate use cases. The goal is to help you choose the right tool for a scenario, configure it with the strongest available credential pattern, and connect it to the right trigger type. By the end of this section you should be able to evaluate an existing automation workload and identify whether it is using the best available authentication and design.
Section 4 learning journey - platform choice through network security
Five automation platforms in scope for this section
Automation platform overview - decision tree for platform choice
Azure Automation Accounts - Runbooks, Schedules, Assets, and Hybrid Workers
Azure Automation Accounts are the most IT-admin-friendly Azure automation platform. They support PowerShell 5.1, PowerShell 7.x, and Python 3 runbooks, and they run on a Microsoft-managed infrastructure. Key use cases include scheduled administrative tasks, event-driven Azure resource management, and hybrid on-premises automation via Hybrid Runbook Workers.
Run As Account retired Sep 30, 2023 - managed identity replaces it
The Run As Account was a certificate-backed service principal that Automation Accounts used to authenticate to Azure. Microsoft retired it on September 30, 2023. Any Automation Account still using a Run As Account must migrate to managed identity. There is no supported path forward that restores Run As Account functionality.
Any Automation Account still using a Run As Account after the September 30, 2023 retirement date is operating on an authentication mechanism that Microsoft no longer supports. Migrate to managed identity immediately.
Managed identity for Automation Accounts - system-assigned vs user-assigned
Automation Accounts support both system-assigned and user-assigned managed identities:
In a runbook, authenticate using the Connect-AzAccount -Identity pattern:
Connect-AzAccount -Identity
# For user-assigned, add: -AccountId "<client-id>"
$secret = Get-AzKeyVaultSecret `
-VaultName "my-keyvault" `
-Name "my-secret" `
-AsPlainText
Get-AzKeyVaultSecret requires the Az.KeyVault module in addition to Az.Accounts. The managed identity also needs a Key Vault data-plane role such as Key Vault Secrets User on the target vault.
Automation Account native source control sync has incomplete support for PowerShell 7.x runbooks. Teams using PS7 should use a CI/CD pipeline (GitHub Actions or Azure DevOps) to deploy runbooks rather than relying on the built-in sync. This limitation does not affect PS 5.1 runbooks.
Hybrid Runbook Workers allow Automation Account runbooks to execute on machines in your on-premises network, in other clouds, or in Azure virtual networks. This enables managing on-premises Active Directory, SQL Server, or other resources that are not reachable from the public internet. Hybrid workers use the Azure Arc agent for connectivity and do not require opening inbound firewall ports.
Azure Logic Apps - trigger, actions, conditions, managed connectors
Logic Apps provide low-code workflow orchestration with hundreds of managed connectors for services like Exchange Online, SharePoint Online, Teams, ServiceNow, and many others. They are the primary platform for Microsoft Sentinel playbooks.
Two hosting models exist:
Logic App connection objects - hidden credential store risks
Logic App connection objects often store OAuth refresh tokens tied to a specific user account. If that user leaves the organization, the connection breaks silently. If the user's account is over-privileged, the Logic App inherits that over-privilege. Audit connector connections regularly and replace user credential connections with managed identity or service principal connections wherever the connector supports it.
Most built-in Azure connectors (Blob Storage, Key Vault, Service Bus) support managed identity. The Standard Logic App tier enables system-assigned managed identity by default.
Logic App managed identity replaces stored OAuth tokens
For connectors that support it, configure authentication at the connection level to use the Logic App's managed identity rather than a stored user credential. This eliminates OAuth token expiry failures and removes the dependency on a specific user account.
Azure Function Apps - trigger sources and three hosting plans
Function Apps are code-based serverless compute that supports C#, Python, JavaScript, and PowerShell. They are the best choice when Logic App connectors do not cover the required integration or when complex data transformation is needed.
Hosting plans:
Function App with managed identity and Application Insights
All Function App hosting plans support managed identity. Enable Application Insights on every Function App used for security automation. Application Insights provides execution telemetry, error tracking, dependency tracing, and performance metrics that are essential for monitoring automation health.
# PowerShell Function App: retrieve secret using managed identity
using namespace System.Net
param($Request, $TriggerMetadata)
Connect-AzAccount -Identity
$secret = Get-AzKeyVaultSecret `
-VaultName $env:KEY_VAULT_NAME `
-Name "api-key" `
-AsPlainText
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = "Retrieved successfully"
})
This example assumes the Function App PowerShell worker has both Az.Accounts and Az.KeyVault available through managed dependencies or bundled modules. Without Az.KeyVault, Get-AzKeyVaultSecret will fail even though the managed identity sign-in succeeds.
Azure DevOps Pipelines - WIF service connection as default
Azure DevOps Pipelines is a CI/CD platform for deploying code and infrastructure. Service connections to Azure use Workload Identity Federation as the default and recommended pattern. WIF-based service connections issue OIDC tokens per pipeline run with no stored secret in Azure DevOps.
Use YAML pipelines for all new work. Classic pipelines cannot be stored in source control, reviewed as code, or audited through normal pull request processes.
Managed identity service connections are also available for self-hosted agents running on Azure VMs or Arc-enabled machines.
GitHub Actions is a CI/CD and automation platform. Workload Identity Federation with Entra via OIDC is the modern authentication approach.
Wif Flow
The flow:
https://token.actions.githubusercontent.com/).sub (subject), repository, ref, and environment.Workflow configuration (no secrets stored in GitHub):
permissions:
id-token: write
contents: read
steps:
- uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
WIF workflow YAML structure - three vars, no secrets
WIF subject claim scope - tight vs wide
Subject claim examples - good narrow scoping vs bad wildcards
Instructor demo - federated credential portal form
A federated credential with an overly permissive subject claim - for example, using a wildcard that matches any branch or any repository - allows any workflow in scope to obtain an Entra access token. This can enable cross-repository privilege escalation if an attacker can trigger a workflow in any matching repository. Always scope subject claims to a specific repository and branch or environment.
Good subject claim examples:
repo:contoso/security-automation:ref:refs/heads/main - only the main branch of one specific reporepo:contoso/security-automation:environment:production - only the production environment of one specific repoBad subject claim example:
repo:org/*:ref:* - any branch in any repo under the organization (overly broad)The decision framework - which automation tool to choose
Discussion: wrong tool for the job - scheduled polling vs event-driven
Use this decision framework:
Authentication best practices - five-point checklist
Sites.Selected over Sites.ReadWrite.All.Network security controls - vNet integration and Private Endpoints with identity layer
Defense-in-depth applies at the network layer in addition to identity controls.
Trigger types overview - five categories and supporting platforms
Alert and incident triggers - SOAR pattern from detection to response
Event-driven triggers - Event Grid, Graph notifications, Service Bus
Match the trigger to the business event:
Have the prepared resource group, repository, and checkpoint screenshots available. Expect audit events and Key Vault RBAC to take a few minutes to settle; retry once before moving to the checkpoint path. Clean up test workflows, Logic Apps, Function Apps, and role assignments created during the lab.
Configure Workload Identity Federation for a GitHub Actions workflow, deploy an HTTP-triggered Logic App you can wire to Entra audit events, and publish a Function App that reads a Key Vault secret and updates a blob by using managed identity.
gh) optional but helpfulLog into your GitHub account and create a test repository named section4-wif, and it can be private if you don't want it to be visible to the public. Use one repository throughout this task. The commands below were validated against a disposable private GitHub repository and a disposable Entra application.
Entra ID > App registrations > New registration.Section4-GitHubWIF-<initials> and choose Single tenant only, then click Register.Certificates & secrets > Federated credentials.GitHub Actions deploying Azure resources, set your GitHub owner and repository, and scope the subject to the main branch.API permissions > Add a permission > Microsoft Graph > Application permissions, and add User.Read.All.Connect-MgGraph -Scopes "Application.ReadWrite.All","Application.Read.All","AppRoleAssignment.ReadWrite.All"
$repoOwner = "<github-owner>"
$repoName = "<section4-wif>"
$displayName = "Lab4-GitHubWIF-$(Get-Date -Format 'yyyyMMddHHmmss')"
$app = Invoke-MgGraphRequest `
-Method POST `
-Uri "https://graph.microsoft.com/v1.0/applications" `
-Body @{ displayName = $displayName; signInAudience = "AzureADMyOrg" }
$servicePrincipal = Invoke-MgGraphRequest `
-Method POST `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals" `
-Body @{ appId = $app.appId }
Invoke-MgGraphRequest `
-Method POST `
-Uri "https://graph.microsoft.com/v1.0/applications/$($app.id)/federatedIdentityCredentials" `
-Body @{
name = "github-main"
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:$repoOwner/$repoName:ref:refs/heads/main"
audiences = @("api://AzureADTokenExchange")
description = "GitHub Actions WIF for main branch"
}
$graphSp = Invoke-MgGraphRequest `
-Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles"
$userReadAllRoleId = ($graphSp.value[0].appRoles | Where-Object {
$_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application"
} | Select-Object -First 1).id
Invoke-MgGraphRequest `
-Method POST `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($servicePrincipal.id)/appRoleAssignments" `
-Body @{
principalId = $servicePrincipal.id
resourceId = $graphSp.value[0].id
appRoleId = $userReadAllRoleId
}
$repoOwner = "<github-owner>"
$repoName = "section4-wif"
$resourceGroupName = "rg-lab-<FirstNameLastInitial>"
$displayName = "Section4-GitHubWIF-$(Get-Date -Format 'yyyyMMddHHmmss')"
$app = az ad app create --display-name $displayName --sign-in-audience AzureADMyOrg | ConvertFrom-Json
$servicePrincipal = az ad sp create --id $app.appId | ConvertFrom-Json
$federatedCredentialBody = @{
name = "github-main"
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:$repoOwner/$repoName`:ref:refs/heads/main"
audiences = @("api://AzureADTokenExchange")
description = "GitHub Actions WIF for main branch"
} | ConvertTo-Json -Depth 5
az ad app federated-credential create --id $app.id --parameters "$federatedCredentialBody"
$resourceGroupId = az group show --name $resourceGroupName --query id -o tsv
az role assignment create `
--assignee-object-id $servicePrincipal.id `
--assignee-principal-type ServicePrincipal `
--role Reader `
--scope $resourceGroupId
$graphSpId = az ad sp list `
--filter "appId eq '00000003-0000-0000-c000-000000000000'" `
--query "[0].id" `
-o tsv
$userReadAllRoleId = az rest `
--method get `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$graphSpId/appRoles?$select=id,value,allowedMemberTypes" `
--query "value[?value=='User.Read.All' && contains(allowedMemberTypes, 'Application')].id | [0]" `
--output tsv
$appRoleAssignmentBody = @{
principalId = $servicePrincipal.id
resourceId = $graphSpId
appRoleId = $userReadAllRoleId
} | ConvertTo-Json -Compress
az rest `
--method post `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($servicePrincipal.id)/appRoleAssignments" `
--headers "Content-Type=application/json" `
--body "$appRoleAssignmentBody"
$tenantId = az account show --query tenantId -o tsv
$subscriptionId = az account show --query id -o tsv
Write-Output "AZURE_CLIENT_ID=$($app.appId)`nAZURE_TENANT_ID=$tenantId`nAZURE_SUBSCRIPTION_ID=$subscriptionId"
# If you have GitHub CLI installed
gh secret set AZURE_CLIENT_ID --repo "$repoOwner/$repoName" --body $app.appId
gh secret set AZURE_TENANT_ID --repo "$repoOwner/$repoName" --body $tenantId
gh secret set AZURE_SUBSCRIPTION_ID --repo "$repoOwner/$repoName" --body $subscriptionId
If you did not use GitHub CLI to add the repository secrets, we will need to go to the repository in GitHub and add them using the values output from the Azure CLI script above. Once those are saved, the workflow can use them.
In GitHub, go to Actions and create a new custom workflow, give it a file name like oidc-validation.yml, and paste the workflow content below.
name: oidc-validation
on:
workflow_dispatch:
push:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Show Azure context
run: az account show --query '{subscription:name, tenant:tenantId, user:user.name}' -o json
- name: Read organization from Microsoft Graph
shell: bash
run: |
graph_token=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)
curl -sS -H "Authorization: Bearer ${graph_token}" "https://graph.microsoft.com/v1.0/organization?\$select=id,displayName" | jq .
Expected outcomes:
- The workflow authenticates through OIDC with no stored client secret
- az account show succeeds in GitHub Actions
- The Graph organization read returns tenant details
Key Vault Secrets Officer or Key Vault Administrator before you create the first secret.Storage Blob Data Contributor on the storage account before you create the blob container and upload the sample file with Azure CLI or Storage Explorer.DemoSecret with the value SuperSecretValue123.names and upload names.txt with a few names, one per line.Key Vault Secrets User on the vault and Storage Blob Data Contributor on the storage account.$resourceGroupName = "rg-lab-<FirstNameLastInitial>"
$location = "<flex-supported-region>" # Use: az functionapp list-flexconsumption-locations -o table
$storageAccountName = "<storage-account>"
$keyVaultName = "<key-vault-name>"
$functionAppName = "<function-app-name>"
$containerName = "names"
$blobName = "names.txt"
az group create `
--name $resourceGroupName `
--location $location
az storage account create `
--name $storageAccountName `
--resource-group $resourceGroupName `
--location $location `
--sku Standard_LRS `
--kind StorageV2 `
--allow-blob-public-access false
az keyvault create `
--name $keyVaultName `
--resource-group $resourceGroupName `
--location $location `
--sku standard `
--enable-rbac-authorization true
$currentUserObjectId = az ad signed-in-user show --query id -o tsv
$keyVaultId = az keyvault show `
--name $keyVaultName `
--resource-group $resourceGroupName `
--query id `
-o tsv
$storageAccountId = az storage account show `
--name $storageAccountName `
--resource-group $resourceGroupName `
--query id `
-o tsv
az role assignment create `
--assignee-object-id $currentUserObjectId `
--assignee-principal-type User `
--role "Key Vault Secrets Officer" `
--scope $keyVaultId
az role assignment create `
--assignee-object-id $currentUserObjectId `
--assignee-principal-type User `
--role "Storage Blob Data Contributor" `
--scope $storageAccountId
az functionapp create `
--name $functionAppName `
--resource-group $resourceGroupName `
--storage-account $storageAccountName `
--flexconsumption-location $location `
--runtime powershell `
--runtime-version 7.4 `
--functions-version 4 `
--https-only true
$functionPrincipalId = az functionapp identity assign `
--name $functionAppName `
--resource-group $resourceGroupName `
--query principalId `
-o tsv
az role assignment create `
--assignee-object-id $functionPrincipalId `
--assignee-principal-type ServicePrincipal `
--role "Key Vault Secrets User" `
--scope $keyVaultId
az role assignment create `
--assignee-object-id $functionPrincipalId `
--assignee-principal-type ServicePrincipal `
--role "Storage Blob Data Contributor" `
--scope $storageAccountId
# If the next commands return Forbidden or AuthorizationPermissionMismatch,
# wait a minute for RBAC propagation and retry them.
az keyvault secret set `
--vault-name $keyVaultName `
--name DemoSecret `
--value "SuperSecretValue123"
@'
Ava
Mateo
Priya
Jordan
'@ | Set-Content -Path (Join-Path $env:TEMP $blobName) -Encoding utf8
az storage container create `
--account-name $storageAccountName `
--name $containerName `
--auth-mode login
az storage blob upload `
--account-name $storageAccountName `
--container-name $containerName `
--name $blobName `
--file (Join-Path $env:TEMP $blobName) `
--auth-mode login `
--overwrite true
$functionProjectRoot = Join-Path $env:TEMP "section4-functionapp"
$functionName = "GetNamesAndSecret"
$zipPath = Join-Path $env:TEMP "section4-functionapp.zip"
Remove-Item $functionProjectRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path (Join-Path $functionProjectRoot $functionName) -Force | Out-Null
@'
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
'@ | Set-Content -Path (Join-Path $functionProjectRoot "host.json") -Encoding utf8
@'
@{
}
'@ | Set-Content -Path (Join-Path $functionProjectRoot "requirements.psd1") -Encoding utf8
@'
{
"bindings": [
{
"authLevel": "function",
"type": "httpTrigger",
"direction": "in",
"name": "Request",
"methods": ["get"]
},
{
"type": "http",
"direction": "out",
"name": "Response"
}
]
}
'@ | Set-Content -Path (Join-Path $functionProjectRoot "$functionName\function.json") -Encoding utf8
@'
param($Request)
$keyVaultToken = Invoke-RestMethod -Method GET -Uri "$($env:IDENTITY_ENDPOINT)?resource=https://vault.azure.net&api-version=2019-08-01" -Headers @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER }
$storageToken = Invoke-RestMethod -Method GET -Uri "$($env:IDENTITY_ENDPOINT)?resource=https://storage.azure.com/&api-version=2019-08-01" -Headers @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER }
$secretResponse = Invoke-RestMethod -Method GET -Uri "https://$($env:KEY_VAULT_NAME).vault.azure.net/secrets/$($env:SECRET_NAME)?api-version=7.4" -Headers @{
Authorization = "Bearer $($keyVaultToken.access_token)"
}
$blobUri = "https://$($env:STORAGE_ACCOUNT_NAME).blob.core.windows.net/$($env:STORAGE_CONTAINER_NAME)/$($env:STORAGE_BLOB_NAME)"
$storageReadHeaders = @{
Authorization = "Bearer $($storageToken.access_token)"
'x-ms-date' = (Get-Date).ToUniversalTime().ToString('R')
'x-ms-version' = '2023-11-03'
}
$blobText = Invoke-RestMethod -Method GET -Uri $blobUri -Headers $storageReadHeaders
$originalNames = @($blobText -split "`r?`n" | Where-Object { $_ -and $_.Trim() })
if (-not $originalNames.Count) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [System.Net.HttpStatusCode]::BadRequest
Body = @{
message = "names.txt was empty."
}
})
return
}
$removedName = $originalNames[-1]
$updatedNames = if ($originalNames.Count -gt 1) {
@($originalNames[0..($originalNames.Count - 2)])
}
else {
@()
}
$storageWriteHeaders = @{
Authorization = "Bearer $($storageToken.access_token)"
'x-ms-blob-type' = 'BlockBlob'
'x-ms-date' = (Get-Date).ToUniversalTime().ToString('R')
'x-ms-version' = '2023-11-03'
}
Invoke-RestMethod -Method PUT -Uri $blobUri -Headers $storageWriteHeaders -Body ($updatedNames -join "`n") -ContentType "text/plain; charset=utf-8"
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [System.Net.HttpStatusCode]::OK
Body = @{
secretName = $env:SECRET_NAME
secretLength = $secretResponse.value.Length
originalNames = $originalNames
removedName = $removedName
updatedNames = $updatedNames
}
})
'@ | Set-Content -Path (Join-Path $functionProjectRoot "$functionName\run.ps1") -Encoding utf8
Compress-Archive -Path (Join-Path $functionProjectRoot '*') -DestinationPath $zipPath
$functionAppName = "<function-app-name>"
$resourceGroupName = "rg-lab-<FirstNameLastInitial>"
$keyVaultName = "<key-vault-name>"
$storageAccountName = "<storage-account>"
$containerName = "names"
$blobName = "names.txt"
$functionName = "GetNamesAndSecret"
$zipPath = Join-Path $env:TEMP "section4-functionapp.zip"
az functionapp config appsettings set `
--name $functionAppName `
--resource-group $resourceGroupName `
--settings KEY_VAULT_NAME=$keyVaultName SECRET_NAME=DemoSecret STORAGE_ACCOUNT_NAME=$storageAccountName STORAGE_CONTAINER_NAME=$containerName STORAGE_BLOB_NAME=$blobName
az functionapp deployment source config-zip `
--name $functionAppName `
--resource-group $resourceGroupName `
--src $zipPath `
--timeout 300
$functionKey = az functionapp function keys list `
--name $functionAppName `
--resource-group $resourceGroupName `
--function-name $functionName `
--query default `
-o tsv
$maxAttempts = 30
$functionUri = "https://$functionAppName.azurewebsites.net/api/$functionName"
$functionHeaders = @{ 'x-functions-key' = $functionKey }
$functionResponse = $null
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$functionResponse = Invoke-RestMethod -Method Get -Uri $functionUri -Headers $functionHeaders
break
}
catch {
$statusCode = if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
[int]$_.Exception.Response.StatusCode
}
else {
$null
}
if ($statusCode -notin 403, 404 -or $attempt -eq $maxAttempts) {
throw
}
Write-Warning "Function endpoint returned $statusCode on attempt $attempt/$maxAttempts. Waiting 15 seconds before retrying."
Start-Sleep -Seconds 15
}
}
$functionResponse
az storage blob download `
--account-name $storageAccountName `
--container-name $containerName `
--name $blobName `
--file (Join-Path $env:TEMP "section4-names-updated.txt") `
--auth-mode login `
--overwrite
Get-Content (Join-Path $env:TEMP "section4-names-updated.txt")
Expected outcomes:
- The Function App acquires Key Vault and Blob Storage tokens from the managed identity endpoint exposed inside the app and never stores a client secret
- The HTTP response shows the secret name and length plus the original, removed, and updated names from names.txt, but not the secret value itself
- The blob in storage is updated in place with the last name removed after the function runs
id-token: write or OIDC login fails even when the federated credential is correct.Contributor is not enough to create the first Key Vault secret when the vault uses RBAC. You need a data-plane role such as Key Vault Secrets Officer.--auth-mode login require a data-plane role such as Storage Blob Data Contributor on the storage account.--flexconsumption-location with PowerShell 7.4 and do not reuse the older Windows Consumption parameters.IDENTITY_ENDPOINT and IDENTITY_HEADER instead of the VM-style IMDS endpoint.Section takeaways - five principles for automation tool selection and hardening
Bridge to Section 5 - source control, CI/CD pipelines, SP lifecycle
Volatile platform behavior and dated claims in this section were checked against these current sources on April 26, 2026:
All links below were reviewed on 2026-03-10.
Section 4 - Automation Tools - Reference Guide