A single reference page that aggregates the hands-on guided lab content from each website section while keeping the existing section theme and copy-button behavior.
Section 1
Microsoft Entra Applications, Managed Identities, and Workload Authentication
Create a single-tenant app registration, inspect the related enterprise application, grant a low-risk Microsoft Graph application permission, request an app-only token, decode the token, and use it to call Microsoft Graph.
For the native PowerShell examples, we will need to obtain an access token to pass with the Bearer token header. Be sure to run the REST-based setup steps in the same terminal session so the admin token stays available in $accessToken:
$tenantId = "<tenant-id>"
az login --tenant $tenantId
$accessToken = az account get-access-token `
--resource-type ms-graph `
--tenant $tenantId `
--query accessToken `
-o tsv
$accessToken
$tenantId = "<tenant-id>"
Connect-AzAccount -Tenant $tenantId
$accessToken = Get-AzAccessToken -ResourceTypeName MSGraph -Tenant $tenantId
$accessToken = $accessToken.Token | ConvertFrom-SecureString -AsPlainText
$accessToken
Most EDRs and SIEMs are configured to collect PowerShell output, including tokens written to the console. This is why we store secrets in a Key Vault or similar and always pass them as a secure string, never written out to the console).
App registrations.New registration.section1-GraphReader-<name>.Single tenant only.Register.Record:
$clientId: Application (client) ID$tenantId: Directory (tenant) ID$appObjectId: Application object IDManaged application in local directory.$spObjectId as the enterprise application / service principal object ID.You should now have $clientId, $tenantId, $appObjectId, and $spObjectId
$displayName = "section1-GraphReader-<name>"
$graphHeaders = @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" }
$app = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/applications" `
-Headers $graphHeaders `
-Body (@{
displayName = $displayName
signInAudience = "AzureADMyOrg"
} | ConvertTo-Json)
$servicePrincipal = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals" `
-Headers $graphHeaders `
-Body (@{
appId = $app.appId
} | ConvertTo-Json)
$clientId = $app.appId
$appObjectId = $app.id
$spObjectId = $servicePrincipal.id
[PSCustomObject]@{
clientId = $clientId
appObjectId = $appObjectId
spObjectId = $spObjectId
}
Connect-MgGraph -TenantId $tenantId -Scopes "Application.ReadWrite.All","Application.Read.All"
$displayName = "section1-GraphReader-<name>"
$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
}
$clientId = $app.appId
$appObjectId = $app.id
$spObjectId = $servicePrincipal.id
[PSCustomObject]@{
clientId = $clientId
appObjectId = $appObjectId
spObjectId = $spObjectId
}
$displayName = "section1-GraphReader-<name>"
$app = az ad app create `
--display-name $displayName `
--sign-in-audience AzureADMyOrg | ConvertFrom-Json
$servicePrincipal = az ad sp create `
--id $app.appId | ConvertFrom-Json
$clientId = $app.appId
$appObjectId = $app.id
$spObjectId = $servicePrincipal.id
[PSCustomObject]@{
clientId = $clientId
appObjectId = $appObjectId
spObjectId = $spObjectId
}
Certificates & secrets.New client secret.# Reuse $appObjectId from Step 2.
$secretEndDateTime = (Get-Date).ToUniversalTime().AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ")
$body = @{
passwordCredential = @{
displayName = "Lab secret"
endDateTime = $secretEndDateTime
}
} | ConvertTo-Json
$secret = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/applications/$appObjectId/addPassword" `
-Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
-Body $body
# Save the secret value - it is only shown once
$clientSecret = $secret.secretText
$clientSecret
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Reuse $appObjectId from Step 2.
$secretEndDateTime = (Get-Date).ToUniversalTime().AddDays(1)
$secret = Add-MgApplicationPassword -ApplicationId $appObjectId -PasswordCredential @{
DisplayName = "Lab secret"
EndDateTime = $secretEndDateTime
}
# Save the secret value - it is only shown once
$clientSecret = $secret.SecretText
$clientSecret
# Reuse $appObjectId from Step 2.
$secretEndDateTime = (Get-Date).ToUniversalTime().AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ")
$clientSecret = az ad app credential reset `
--id $appObjectId `
--append `
--display-name "Lab secret" `
--end-date $secretEndDateTime `
--query password `
--output tsv
$clientSecret
This lab uses a client secret for convenience. In production, a client secret combined with broad application permissions (like Mail.ReadWrite for all mailboxes) recreates the same risk pattern as legacy service accounts with passwords. Prefer managed identities or federated credentials whenever possible.
Reminder:
API permissionsMicrosoft GraphApplication permissionsUser.Read.All# Step 4a: Add User.Read.All to required resource access
# Reuse $appObjectId from Step 2.
# Microsoft Graph service principal appId is always 00000003-0000-0000-c000-000000000000
# User.Read.All role ID: df021288-bdef-4463-88db-98f22de89214
$body = @{
requiredResourceAccess = @(
@{
resourceAppId = "00000003-0000-0000-c000-000000000000"
resourceAccess = @(
@{
id = "df021288-bdef-4463-88db-98f22de89214"
type = "Role"
}
)
}
)
} | ConvertTo-Json -Depth 4
Invoke-RestMethod `
-Method Patch `
-Uri "https://graph.microsoft.com/v1.0/applications/$appObjectId" `
-Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
-Body $body
Cloud Shell may not be able to consent to permissions below, so you may need to follow the directions in the console to logout and login using interactive authentication.
# Step 4b: Grant admin consent (create appRoleAssignment on the service principal)
# Reuse $spObjectId from Step 2.
# Get the Microsoft Graph service principal in your tenant
$graphSp = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" `
-Headers @{ Authorization = "Bearer $accessToken" }
$graphSpId = $graphSp.value[0].id
$consentBody = @{
principalId = $spObjectId
resourceId = $graphSpId
appRoleId = "df021288-bdef-4463-88db-98f22de89214"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$spObjectId/appRoleAssignments" `
-Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
-Body $consentBody
# Step 4a: Add User.Read.All to required resource access
# Reuse $appObjectId from Step 2.
# Microsoft Graph service principal appId is always 00000003-0000-0000-c000-000000000000
# User.Read.All role ID: df021288-bdef-4463-88db-98f22de89214
Connect-MgGraph -Scopes "Application.ReadWrite.All"
$params = @{
requiredResourceAccess = @(
@{
resourceAppId = "00000003-0000-0000-c000-000000000000"
resourceAccess = @(
@{
id = "df021288-bdef-4463-88db-98f22de89214"
type = "Role"
}
)
}
)
}
Update-MgApplication -ApplicationId $appObjectId -BodyParameter $params
Cloud Shell may not be able to consent to permissions below, so you may need to follow the directions in the console to logout and login using interactive authentication.
# Step 4b: Grant admin consent (create appRoleAssignment on the service principal)
# Reuse $spObjectId from Step 2.
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All","Application.Read.All"
# Resolve the Microsoft Graph service principal object ID in this tenant
$graphSpId = Get-MgServicePrincipal `
-Filter "appId eq '00000003-0000-0000-c000-000000000000'" |
Select-Object -First 1 -ExpandProperty Id
$params = @{
principalId = $spObjectId
resourceId = $graphSpId
appRoleId = "df021288-bdef-4463-88db-98f22de89214"
}
New-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $spObjectId `
-BodyParameter $params
# Step 4a: Add User.Read.All to required resource access
# Reuse $appObjectId from Step 2.
# Microsoft Graph service principal appId is always 00000003-0000-0000-c000-000000000000
# User.Read.All role ID: df021288-bdef-4463-88db-98f22de89214
az ad app permission add `
--id $appObjectId `
--api 00000003-0000-0000-c000-000000000000 `
--api-permissions df021288-bdef-4463-88db-98f22de89214=Role
Cloud Shell may not be able to consent to permissions below, so you may need to follow the directions in the console to logout and login using interactive authentication.
# Step 4b: Grant admin consent for the configured app permissions
# Reuse the application object ID from step 4a.
# This grants admin consent for all permissions currently configured on the app registration.
az ad app permission admin-consent `
--id $appObjectId
# Reuse $tenantId from Step 1, $clientId from Step 2, and $clientSecret from Step 3.
$tokenResponse = Invoke-RestMethod `
-Method Post `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body @{
client_id = $clientId
client_secret = $clientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$accessToken = $tokenResponse.access_token
# Reuse $tenantId from Step 1, $clientId from Step 2, and $clientSecret from Step 3.
$secureClientSecret = ConvertTo-SecureString $clientSecret -AsPlainText -Force
$clientSecretCredential = New-Object System.Management.Automation.PSCredential($clientId, $secureClientSecret)
Connect-MgGraph `
-TenantId $tenantId `
-ClientId $clientId `
-ClientSecretCredential $clientSecretCredential `
-NoWelcome
# Capture the same app-only token so the next step can decode it.
$tokenResponse = Invoke-RestMethod `
-Method Post `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body @{
client_id = $clientId
client_secret = $clientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$accessToken = $tokenResponse.access_token
# Reuse $tenantId from Step 1, $clientId from Step 2, and $clientSecret from Step 3.
az login `
--service-principal `
--username $clientId `
--password $clientSecret `
--tenant $tenantId `
--skip-sub
$accessToken = az account get-access-token `
--resource-type ms-graph `
--tenant $tenantId `
--query accessToken `
--output tsv
$accessToken
$tokenParts = $accessToken.Split(".")
$payload = $tokenParts[1].Replace("-", "+").Replace("_", "/")
switch ($payload.Length % 4) {
2 { $payload += "==" }
3 { $payload += "=" }
}
$claimsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload))
$claims = $claimsJson | ConvertFrom-Json
$claims | Format-List aud, tid, appid, roles, scp, exp
Expected observations:
appid matches the app registrationroles includes User.Read.Allscp is empty or missing$headers = @{ Authorization = "Bearer $accessToken" }
$response = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/users" `
-Headers $headers
$response
$response.value
Open service principal sign-ins in the Entra admin center and look for the app-only sign-in to Microsoft Graph.
Entra ID → Monitoring & health → Sign-in logs.Service principal sign-ins tab.Microsoft Graph.Service principal sign-in logs are separate from user sign-in logs. When troubleshooting app-only access failures, always check this tab first. The log entry shows whether the token was issued, what resource was targeted, and whether any Conditional Access policies were evaluated. If the sign-in succeeded but the API call returned 403, the problem is in the permission grant, not the authentication.
Section 2
OAuth Permissions, Admin Consent, and Security Attacks
Use Graph Explorer to experience delegated versus application-only access, review App Governance for overprivileged applications, revoke a delegated permission grant, and verify a Conditional Access policy blocking device code flow.
For the native PowerShell examples, we will need to obtain an access token to pass with the Bearer token header. Be sure to run the REST-based setup steps in the same terminal session so the admin token stays available in $accessToken:
$tenantId = "<tenant-id>"
az login --tenant $tenantId
$accessToken = az account get-access-token `
--resource-type ms-graph `
--tenant $tenantId `
--query accessToken `
-o tsv
$accessToken
$tenantId = "<tenant-id>"
Connect-AzAccount -Tenant $tenantId
$accessToken = Get-AzAccessToken -ResourceTypeName MSGraph -Tenant $tenantId
$accessToken = $accessToken.Token | ConvertFrom-SecureString -AsPlainText
$accessToken
Most EDRs and SIEMs are configured to collect PowerShell output, including tokens written to the console. This is why we store secrets in a Key Vault or similar and always pass them as a secure string, never written out to the console).
Use one setup path and keep the variables it creates in the same terminal session. For the Native PowerShell path, reuse the admin token from Step 1 so the REST calls below can keep using $accessToken. Each example creates a disposable app, a delegated grant, and an application grant so the later steps have predictable objects to inspect and revoke.
Note: If using Cloud Shell, you will need to log in using az login --use-device-code for permissions to create apps and grant consent.
# Reuse $accessToken from Step 1.
$graphHeaders = @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" }
$demoName = "Section2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/applications" `
-Headers $graphHeaders `
-Body (@{
displayName = $demoName
signInAudience = "AzureADMyOrg"
} | ConvertTo-Json)
$demoSp = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals" `
-Headers $graphHeaders `
-Body (@{
appId = $demoApp.appId
} | ConvertTo-Json)
$demoSpObjectId = $demoSp.id
$signedInUser = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/me" `
-Headers @{ Authorization = "Bearer $accessToken" }
$signedInUserId = $signedInUser.id
$graphSp = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" `
-Headers @{ Authorization = "Bearer $accessToken" }
$graphSpId = $graphSp.value[0].id
$demoDelegatedGrant = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" `
-Headers $graphHeaders `
-Body (@{
clientId = $demoSpObjectId
consentType = "Principal"
principalId = $signedInUserId
resourceId = $graphSpId
scope = "openid profile offline_access User.Read"
} | ConvertTo-Json)
$demoAppRoleAssignment = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments" `
-Headers $graphHeaders `
-Body (@{
principalId = $demoSpObjectId
resourceId = $graphSpId
appRoleId = "498476ce-e0fe-48b0-b801-37ba7e2685c6"
} | ConvertTo-Json)
[PSCustomObject]@{
demoAppObjectId = $demoAppObjectId
demoSpObjectId = $demoSpObjectId
delegatedGrantId = $demoDelegatedGrant.id
appRoleAssignmentId = $demoAppRoleAssignment.id
}
Connect-MgGraph -TenantId $tenantId -Scopes "Application.ReadWrite.All","Application.Read.All","DelegatedPermissionGrant.ReadWrite.All","AppRoleAssignment.ReadWrite.All","User.Read"
$demoName = "Section2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = New-MgApplication -DisplayName $demoName -SignInAudience "AzureADMyOrg"
$demoSp = New-MgServicePrincipal -AppId $demoApp.AppId
$demoAppObjectId = $demoApp.Id
$demoSpObjectId = $demoSp.Id
$signedInUser = Get-MgUser -UserId (Get-MgContext).Account
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" | Select-Object -First 1
$demoDelegatedGrant = New-MgOauth2PermissionGrant -BodyParameter @{
clientId = $demoSpObjectId
consentType = "Principal"
principalId = $signedInUser.Id
resourceId = $graphSp.Id
scope = "openid profile offline_access User.Read"
}
$demoAppRoleAssignment = New-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $demoSpObjectId `
-BodyParameter @{
principalId = $demoSpObjectId
resourceId = $graphSp.Id
appRoleId = "498476ce-e0fe-48b0-b801-37ba7e2685c6"
}
$tenantId = "<tenant-id>"
az login --tenant $tenantId
$demoName = "Section2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = az ad app create --display-name $demoName --sign-in-audience AzureADMyOrg | ConvertFrom-Json
$demoSp = az ad sp create --id $demoApp.appId | ConvertFrom-Json
$demoAppObjectId = $demoApp.id
$demoSpObjectId = $demoSp.id
$signedInUserId = az ad signed-in-user show --query id -o tsv
$graphAppId = "00000003-0000-0000-c000-000000000000"
$graphServicePrincipal = az ad sp show --id $graphAppId | ConvertFrom-Json
$graphSpId = $graphServicePrincipal.id
$appReadAllRoleId = ($graphServicePrincipal.appRoles | Where-Object {
$_.value -eq "Application.Read.All" -and $_.allowedMemberTypes -contains "Application"
} | Select-Object -First 1 -ExpandProperty id)
# Use the native delegated grant command so the grant stays scoped to the signed-in user.
az ad app permission grant `
--id $demoApp.appId `
--api $graphSpId `
--scope "openid profile offline_access User.Read" `
--consent-type Principal `
--principal-id $signedInUserId
# Request and admin-consent the Graph application permission separately.
az ad app permission add `
--id $demoApp.appId `
--api $graphAppId `
--api-permissions "$appReadAllRoleId=Role"
# Permissions will take a while to propagate
az ad app permission admin-consent --id $demoApp.appId
$demoDelegatedGrant = az ad app permission list-grants `
--id $demoApp.appId `
--query "[0]" `
--output json | ConvertFrom-Json
if (-not $demoDelegatedGrant) {
throw "Unable to locate the delegated grant object after az ad app permission grant."
} else { $demoDelegatedGrant }
# Azure CLI does not expose a direct app-role-assignment command that returns the assignment id.
$demoAppRoleAssignment = az rest `
--method get `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments?$filter=resourceId eq '$graphSpId' and appRoleId eq '$appReadAllRoleId'" `
--query "value[0]" `
--output json | ConvertFrom-Json
if (-not $demoAppRoleAssignment) {
throw "Unable to locate the app role assignment object after admin consent."
} else { $demoAppRoleAssignment }
https://developer.microsoft.com/en-us/graph/graph-explorer in a browser.Sign in to Graph Explorer or click the user image in the top right to sign in.If someone consents for the organization, the dialog will not appear for individual users. In the lab, you can remove consent from Entra admin center, then try again.
If Graph Explorer is blocked by user-consent policy in your tenant, continue with the disposable demo app and delegated grant from Step 2. The grant object behaves the same for enumeration and revocation.
/meGET https://graph.microsoft.com/v1.0/me.Expected: returns your own user object. This is delegated access.
/users and expand scopeGET https://graph.microsoft.com/v1.0/users.Expected: the first call returns a 403 Forbidden until the broader delegated scope is consented. After consent, the users call succeeds.
⚠️ If someone has consented for the organization, the dialog will not appear for individual users. In the lab, you can either remove consent from Entra admin center, then try again, or you can try different API endpoints and permission combinations.
Modify Permissions, then click Consent and accept the consent but do not consent for the organization.GET https://graph.microsoft.com/v1.0/users.# Reuse the signed-in user token from Step 1.
$headers = @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/me" `
-Headers $headers
# This returns 403 unless the current client has User.Read.All consented.
Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/users" `
-Headers $headers
Connect-MgGraph -Scopes "User.Read"
Get-MgUser -UserId "me"
# Reconnect with the broader delegated scope if you want to test directory reads.
Connect-MgGraph -Scopes "User.Read","User.Read.All"
Get-MgUser -Top 5
az login --tenant $tenantId
az rest `
--method get `
--url "https://graph.microsoft.com/v1.0/me"
az rest `
--method get `
--url "https://graph.microsoft.com/v1.0/users"
These alternate clients use their own app registrations instead of Graph Explorer, but they demonstrate the same delegated model: the token carries scp scopes and stays bounded by user context.
Enterprise Applications.Graph Explorer to review the delegated grant created in Step 3.Section2-PermissionsDemo-* enterprise app from Step 2 and open Permissions.Record the permission grant IDs you can see.
The code paths below intentionally query the disposable demo app from Step 2 because it gives you a predictable delegated grant ID for enumeration and revocation.
Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" `
-Headers @{ Authorization = "Bearer $accessToken" }
Get-MgOauth2PermissionGrant -Filter "clientId eq '$demoSpObjectId'" |
Select-Object Id, Scope, ConsentType, PrincipalId
$grants = az ad app permission list-grants `
--id $demoApp.appId `
--output json | ConvertFrom-Json
$grants | Select-Object id, scope, consentType, principalId
Permissions blade open from Step 5.# Revoke a specific delegated grant.
$grantId = $demoDelegatedGrant.id
Invoke-RestMethod `
-Method Delete `
-Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$grantId" `
-Headers @{ Authorization = "Bearer $accessToken" }
Import-Module Microsoft.Graph.Identity.SignIns
Connect-MgGraph -Scopes "DelegatedPermissionGrant.ReadWrite.All"
# Remove the disposable demo grant from Step 2, or another non-essential grant ID you found above.
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $demoDelegatedGrant.Id
# Revoke a specific delegated grant.
$grantId = $demoDelegatedGrant.id
az rest `
--method delete `
--uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$grantId"
Step 1: Remove the consent grant. Step 2: If a compromised user session is involved, revoke that user's refresh tokens separately. Step 3: For service principals, disable the app or rotate credentials and remember that existing access tokens remain valid until expiry.
Expected observation: the portal shows the permission gone after the revocation call runs. Existing Graph Explorer or delegated client sessions still work until their access tokens expire, demonstrating that permission revocation is not immediate token revocation.
Microsoft Defender XDR.Cloud Apps > App Governance.Mail.ReadWrite, Files.ReadWrite.All, Application.ReadWrite.All, or any other application permissions you want to look at.Expected: identify apps that are inactive, unowned, or holding high-privilege permissions that exceed their apparent purpose. This step is intentionally portal-first because App Governance is the purpose-built investigation surface.
Entra ID > Conditional Access > Policies.Report-only mode initially.Expected: the policy appears in the list. In Report-only mode, sign-in logs show what would have been blocked without affecting current sessions.
# Create a CA policy blocking device code flow (Report-only)
# Requires Security Administrator or Conditional Access Administrator.
# Replace the excluded user with a real break-glass account before using this outside a lab.
$policyBody = @{
displayName = "Block Device Code Flow-$(Get-Date -Format 'yyyyMMddHHmmss')"
state = "enabledForReportingButNotEnforced"
conditions = @{
clientAppTypes = @("all")
applications = @{
includeApplications = @("All")
}
users = @{
includeUsers = @("All")
excludeUsers = @("<break-glass-user-id>")
}
authenticationFlows = @{
transferMethods = "deviceCodeFlow"
}
}
grantControls = @{
operator = "OR"
builtInControls = @("block")
}
} | ConvertTo-Json -Depth 6
$policy = Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" `
-Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
-Body $policyBody
$policy
Import-Module Microsoft.Graph.Identity.SignIns
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"
$params = @{
displayName = "Block Device Code Flow-$(Get-Date -Format 'yyyyMMddHHmmss')"
state = "enabledForReportingButNotEnforced"
conditions = @{
clientAppTypes = @("all")
applications = @{
includeApplications = @("All")
}
users = @{
includeUsers = @("All")
excludeUsers = @("<break-glass-user-id>")
}
authenticationFlows = @{
transferMethods = "deviceCodeFlow"
}
}
grantControls = @{
operator = "OR"
builtInControls = @("block")
}
}
$policy = New-MgIdentityConditionalAccessPolicy -BodyParameter $params
$policy
$policyBody = @{
displayName = "Block Device Code Flow-$(Get-Date -Format 'yyyyMMddHHmmss')"
state = "enabledForReportingButNotEnforced"
conditions = @{
clientAppTypes = @("all")
applications = @{
includeApplications = @("All")
}
users = @{
includeUsers = @("All")
excludeUsers = @("<break-glass-user-id>")
}
authenticationFlows = @{
transferMethods = "deviceCodeFlow"
}
}
grantControls = @{
operator = "OR"
builtInControls = @("block")
}
} | ConvertTo-Json -Depth 6
$policy = az rest `
--method post `
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" `
--headers "Content-Type=application/json" `
--body "$policyBody"
$policy
Invoke-RestMethod `
-Method Delete `
-Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($demoDelegatedGrant.id)" `
-Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod `
-Method Delete `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments/$($demoAppRoleAssignment.id)" `
-Headers @{ Authorization = "Bearer $accessToken" }
# Remove this too if you created the report-only device-code policy in Step 8.
Invoke-RestMethod `
-Method Delete `
-Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$($policy.Id);" `
-Headers @{ Authorization = "Bearer $accessToken" }
az ad sp delete --id $demoSpObjectId
az ad app delete --id $demoAppObjectId
Connect-MgGraph -Scopes "Application.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All","AppRoleAssignment.ReadWrite.All"
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $demoDelegatedGrant.Id
Remove-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $demoSpObjectId -AppRoleAssignmentId $demoAppRoleAssignment.Id
# Remove this too if you created the report-only device-code policy in Step 8.
Remove-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $policy.Id
Remove-MgServicePrincipal -ServicePrincipalId $demoSpObjectId
Remove-MgApplication -ApplicationId $demoAppObjectId
az rest `
--method delete `
--uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($demoDelegatedGrant.id)"
az rest `
--method delete `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments/$($demoAppRoleAssignment.id)"
# Remove this too if you created the report-only device-code policy in Step 8.
az rest `
--method delete `
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$(($policy | ConvertFrom-Json).id)"
az ad sp delete --id $demoSpObjectId
az ad app delete --id $demoAppObjectId
Section 3
Token Acquisition, IMDS, Microsoft Graph, and Azure Resource Manager
Connect to Microsoft Graph from an Automation Account runbook using managed identity, verify the app-only permission in the token, compare Graph PowerShell to raw REST, and implement pagination and $select correctly.
Note: The shared lab environment may have quota limits that require we change location, and it may be easier to do some of these tasks in the Azure portal. Hopefully we can walk through this one together.
User.Read.All application permission to the Automation Account identityCreate a new Automation Account with a random suffix so each learner gets a unique name. If provisioning fails in one region, switch to westus or westus2 and run the command again.
Azure CLI:
# Resource group name is rg-lab-firstnamelastinitial $resourceGroupName = "<rg-lab-firstlastinitial>" $location = "eastus" $automationAccountName = "aa-s3lab-$((Get-Random -Maximum 999))" $subscriptionId = az account show --query id -o tsv # Create the automation account az automation account create ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --location $location # Enable the Managed Identity az rest ` --method patch ` --uri "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Automation/automationAccounts/$automationAccountName`?api-version=2020-01-13-preview" ` --headers "Content-Type=application/json" ` --body '{"identity":{"type":"SystemAssigned"}}' # Get Managed Identity SP Object ID $managedIdentitySpObjectId = az automation account show ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --query identity.principalId ` -o tsv if (-not $managedIdentitySpObjectId) { throw "Managed identity enablement did not return a principalId." } Write-Output "Created Automation Account: $automationAccountName"
Use the Automation Account principal ID as the target for the app role assignment. The later runbook steps assume the managed identity already has User.Read.All.
Identity and confirm System assigned is On.Object (principal) ID so you can verify the same service principal in Entra after the grant.$resourceGroupName = "<rg-lab-firstlastinitial>"
$automationAccountName = "<aa-s3lab-###>"
$managedIdentitySpObjectId = az automation account show `
--automation-account-name $automationAccountName `
--resource-group $resourceGroupName `
--query identity.principalId `
-o tsv
$adminAccessToken = az account get-access-token `
--resource-type ms-graph `
--query accessToken `
-o tsv
$graphSp = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles" `
-Headers @{ Authorization = "Bearer $adminAccessToken" }
$graphSpId = $graphSp.value[0].id
$userReadAllRoleId = ($graphSp.value[0].appRoles | Where-Object {
$_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application"
} | Select-Object -First 1).id
$existingAssignments = Invoke-RestMethod `
-Method Get `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" `
-Headers @{ Authorization = "Bearer $adminAccessToken" }
if (-not ($existingAssignments.value | Where-Object {
$_.resourceId -eq $graphSpId -and $_.appRoleId -eq $userReadAllRoleId
})) {
Invoke-RestMethod `
-Method Post `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" `
-Headers @{ Authorization = "Bearer $adminAccessToken"; "Content-Type" = "application/json" } `
-Body (@{
principalId = $managedIdentitySpObjectId
resourceId = $graphSpId
appRoleId = $userReadAllRoleId
} | ConvertTo-Json
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All","Application.Read.All"
$resourceGroupName = "<rg-lab-firstlastinitial>"
$automationAccountName = "<aa-s3lab-###>"
$managedIdentitySpObjectId = az automation account show `
--automation-account-name $automationAccountName `
--resource-group $resourceGroupName `
--query identity.principalId `
-o tsv
$graphSp = Invoke-MgGraphRequest `
-Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles"
$graphSpId = $graphSp.value[0].id
$userReadAllRoleId = ($graphSp.value[0].appRoles | Where-Object {
$_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application"
} | Select-Object -First 1).id
$existingAssignments = Invoke-MgGraphRequest `
-Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments"
if (-not ($existingAssignments.value | Where-Object {
$_.resourceId -eq $graphSpId -and $_.appRoleId -eq $userReadAllRoleId
})) {
Invoke-MgGraphRequest `
-Method POST `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" `
-Body @{
principalId = $managedIdentitySpObjectId
resourceId = $graphSpId
appRoleId = $userReadAllRoleId
}
}
# Get the Graph API Service Principal ID
$graphSp = az rest `
--method get `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles" `
--query "value[0]" `
--output json | ConvertFrom-Json
$graphSpId = $graphSp.id
# Get the User.Read.All appRole ID
$userReadAllRoleId = ($graphSp.appRoles | Where-Object {
$_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application"
} | Select-Object -First 1).id
if (-not $userReadAllRoleId) {
throw "Could not resolve User.Read.All application appRoleId from Microsoft Graph service principal."
}
# Grant User.Read.All appRole permission
az rest `
--method post `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" `
--headers "Content-Type=application/json" `
--body "{`"principalId`":`"$managedIdentitySpObjectId`",`"resourceId`":`"$graphSpId`",`"appRoleId`":`"$userReadAllRoleId`"}"
Navigate to the Azure portal and open your Automation account. Switch to the new Runtime Environment Experience and navigate to Process Automation -> Runtime Environments. Create a new PowerShell 7.6 runtime with `the Microsoft.Graph.Authentication` module added from the gallery.
Now navigate to Process Automation -> Runbooks, and click Create. In the Create a runbook wizard, provide a name, select PowerShell as the runbook type, select your new PowerShell 7.6 runtime environment, and create the runbook. Once the runbook opens, click Edit - Edit in portal, then paste the code below into the editor.
Azure Automation runbook:
Connect with managed identity and query Graph
Connect-MgGraph -Identity $users = Invoke-MgGraphRequest ` -Method GET ` -Uri "https://graph.microsoft.com/v1.0/users?`$top=5&`$select=id,displayName,userPrincipalName" $users.value | Select-Object displayName, userPrincipalName | Format-Table
Click Test pane, then click Start and check the results.
Expected outcome: a short table of users from the tenant. If you receive a 403, permissions may still be replicating or you had an issue in Step 2. You can inspect the managed identity's roles claim in Step 5 to troubleshoot.
Azure Automation runbook:
Get the Graph token explicitly and call
/usersDisable-AzContextAutosave -Scope Process Connect-AzAccount -Identity $tokenObject = Get-AzAccessToken -ResourceTypeName MSGraph $token = if ($tokenObject.Token -is [securestring]) { [System.Net.NetworkCredential]::new('', $tokenObject.Token).Password } else { $tokenObject.Token } $token $headers = @{ Authorization = "Bearer $token" } $response = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/users?`$top=5&`$select=id,displayName,userPrincipalName" ` -Headers $headers $response.value | Select-Object displayName, userPrincipalName
Click Test pane, then click Start and check the results.
Expected outcome: the same data appears again, but now you can see the raw response shape and the bearer token boundary directly.
Local PowerShell:
Use
$token = Get-Clipboardto get the token to the clipboard.$tokenParts = $token.Split(".") $payload = $tokenParts[1].Replace("-", "+").Replace("_", "/") switch ($payload.Length % 4) { 2 { $payload += "==" } 3 { $payload += "=" } } $claims = [Text.Encoding]::UTF8.GetString( [Convert]::FromBase64String($payload) ) | ConvertFrom-Json $claims | Select-Object aud, oid, roles, scp, exp | Format-List
Expected observations:
aud is https://graph.microsoft.comroles contains User.Read.Allscp is absent or empty because this is app-only accessoid matches the Automation Account service principal object IDPaste the token into https://jwt.ms if you want to verify the same claims visually in the browser.
Local PowerShell or Update Azure Automation runbook:
Follow
@odata.nextLinkuntil the collection is complete$headers = @{ Authorization = "Bearer $token" } $uri = "https://graph.microsoft.com/v1.0/users?`$top=2&`$select=displayName,userPrincipalName" $allUsers = @() do { $page = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers $allUsers += $page.value $uri = $page.'@odata.nextLink' Write-Output "Retrieved $($allUsers.Count) users so far..." } while ($uri) Write-Output "Total users retrieved: $($allUsers.Count)"
Expected outcome: multiple loop iterations when $top=2 is low enough to force pagination.
Azure Automation runbook:
Request only the fields the runbook actually needs
$response = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/users?`$select=displayName,userPrincipalName,accountEnabled" ` -Headers $headers $response.value | Select-Object displayName, userPrincipalName
Expected outcome: the response payload is smaller and the runbook only processes the properties it asked for.
Admin takeaways: 5 principles to remember from this section
Section 4
Automation Accounts, Logic Apps, Function Apps, and CI/CD
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
Section 5
Source Control, Observability, AI-Assisted Development, and Service Principal Hygiene
Configure source control sync for an Automation Account, lint a runbook with PSScriptAnalyzer, inventory expiring Entra application credentials, send Automation diagnostics to Log Analytics, and create a Monitor alert rule from a Log Analytics query.
Account Settings -> Source control and click Add to add a new connection.Source control type, authenticate to GitHub, then select your repository, the main branch, and the /runbooks folder.Add file -> Create new file. For the file name, use /runbooks/hello-world.ps1 which will create the /runbooks folder for you.Start sync. You can monitor the progress on the Sync jobs tab. After the first sync job, navigate to Process Automation -> Runbooks and confirm the hello-world runbook was synced in.Azure CLI:
$resourceGroupName = "rg-lab-<FirstNameLastInitial>" $automationAccountName = "<automation-account>" $repoUrl = "https://github.com/<github-owner>/<repo-name>.git" gh auth login $githubToken = gh auth token # Source control sync requires the Automation Account managed identity and a Contributor assignment on the Automation Account itself. $subscriptionId = az account show --query id -o tsv $automationId = az automation account show --automation-account-name $automationAccountName --resource-group $resourceGroupName --query id -o tsv $automationPrincipalId = az automation account show --automation-account-name $automationAccountName --resource-group $resourceGroupName --query identity.principalId -o tsv az role assignment create ` --assignee-object-id $automationPrincipalId ` --assignee-principal-type ServicePrincipal ` --role Contributor ` --scope $automationId az automation source-control create ` --resource-group $resourceGroupName ` --automation-account-name $automationAccountName ` --name github-runbooks ` --repo-url $repoUrl ` --branch main ` --source-type GitHub ` --folder-path /runbooks ` --access-token $githubToken ` --token-type PersonalAccessToken ` --auto-sync false ` --publish-runbook true # In PowerShell, preserve the required empty string for commit-id with the stop-parsing operator. az automation source-control sync-job create --resource-group $resourceGroupName --automation-account-name $automationAccountName --source-control-name github-runbooks --job-id (New-Guid) --commit-id "" az automation runbook list ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --query "[].{name:name,state:state}" ` -o table
Expected outcomes:
- The sync job reaches Succeeded
- The source-controlled runbook appears in the Automation Account
- The imported runbook shows as Published
Native PowerShell:
Create a sample runbook and lint it
@' function Invoke-LegacyLogin { param([string]$Password) Write-Host "Using password input" } Invoke-LegacyLogin -Password "hardcoded-password-value-here" $password = ConvertTo-SecureString "plaintext" -AsPlainText -Force '@ | Set-Content -Path .\Sample-Runbook.ps1 -Encoding utf8 Install-Module PSScriptAnalyzer -Scope CurrentUser -Force Invoke-ScriptAnalyzer -Path .\Sample-Runbook.ps1 -Severity Error, Warning Invoke-ScriptAnalyzer -Path .\Sample-Runbook.ps1 ` -IncludeRule PSAvoidUsingPlainTextForPassword,PSAvoidUsingConvertToSecureStringWithPlainText,PSAvoidUsingWriteHost
Expected outcomes:
- PSScriptAnalyzer flags the plain-text password rules
- Write-Host is called out as a lint issue in the sample file
- Re-running after a fix removes the matching rule from the results
Connect-MgGraph -Scopes "Application.Read.All"
$cutoff = (Get-Date).AddDays(30)
$uri = "https://graph.microsoft.com/v1.0/applications?`$select=displayName,appId,passwordCredentials&`$top=999"
$results = @()
do {
$response = Invoke-MgGraphRequest -Method GET -Uri $uri
$results += $response.value
$uri = $response.'@odata.nextLink'
} while ($uri)
$results | ForEach-Object {
$app = $_
$app.passwordCredentials | Where-Object {
[datetime]$_.endDateTime -lt $cutoff -and [datetime]$_.endDateTime -gt (Get-Date)
} | ForEach-Object {
[PSCustomObject]@{
DisplayName = $app.displayName
AppId = $app.appId
ExpiresOn = $_.endDateTime
DaysLeft = [int]([datetime]$_.endDateTime - (Get-Date)).TotalDays
}
}
} | Sort-Object DaysLeft | Format-Table -AutoSize
Expected outcomes:
- The query returns app registrations with credentials expiring inside the chosen window
- The output includes display name, app ID, expiry, and days remaining
Diagnostic settings and add a setting.JobLogs, JobStreams, and AuditEvent.Azure CLI:
$resourceGroup = "rg-lab-<FirstNameLastInitial>" $automationId = az automation account show ` --automation-account-name "<automation-account>" ` --resource-group $resourceGroup ` --query id ` -o tsv $workspaceId = az monitor log-analytics workspace show ` --resource-group $resourceGroup ` --workspace-name "la-lab-<FirstNameLastInitial>" ` --query id ` -o tsv az monitor diagnostic-settings create ` --name send-to-law ` --resource $automationId ` --workspace $workspaceId ` --logs '[{"category":"JobLogs","enabled":true},{"category":"JobStreams","enabled":true},{"category":"AuditEvent","enabled":true}]' ` --metrics '[{"category":"AllMetrics","enabled":true}]'
Next we need to generate data for the query to discover. Go run one of the runbooks, and then we will have to wait about 5 minutes until data becomes available.
Azure CLI:
$workspaceCustomerId = az monitor log-analytics workspace show ` --resource-group "<workspace-resource-group>" ` --workspace-name "<workspace-name>" ` --query customerId ` -o tsv az monitor log-analytics query ` --workspace $workspaceCustomerId ` --analytics-query 'AzureDiagnostics | where ResourceProvider == "MICROSOFT.AUTOMATION" | where Category in ("JobLogs", "JobStreams", "AuditEvent") | where TimeGenerated > ago(1h) | project TimeGenerated, RunbookName_s, Category, ResultType, ResultDescription | order by TimeGenerated desc'
Expected outcomes:
- The diagnostic setting is created successfully
- After the first post-enable runbook job, the Automation records land in Log Analytics
- You can pivot on Category, RunbookName_s, and ResultType
Monitor > Alerts > Create.AzureDiagnostics
| where ResourceProvider == 'MICROSOFT.AUTOMATION'
| where Category == 'JobLogs'
| where ResultType == 'Failed'
| where TimeGenerated > ago(15m)"Azure CLI:
Create a disabled scheduled-query alert rule first
$workspaceId = az monitor log-analytics workspace show ` --resource-group "<workspace-resource-group>" ` --workspace-name "<workspace-name>" ` --query id ` -o tsv az monitor scheduled-query create ` --resource-group "rg-lab-<FirstNameLastInitial>" ` --name "Lab5-RunbookAlert" ` --scopes $workspaceId ` --condition "count 'AutomationJobs' > 0" ` --condition-query AutomationJobs="AzureDiagnostics | where ResourceProvider == 'MICROSOFT.AUTOMATION' | where Category == 'JobLogs' | where ResultType == 'Failed' | where TimeGenerated > ago(15m)" ` --evaluation-frequency 15m ` --window-size 15m ` --severity 3 ` --disabled true
Expected outcomes:
- The rule is created successfully
- You can inspect the condition safely before enabling it
- Once diagnostics are flowing and the query returns the expected schema, you can remove --disabled true
The next step is to add exit 1 to one of your runbooks and run it, then you should be able to query it. Enabling the scheduled query should alert on it the next time you run it, and we can even set up automated actions based on the alert.
az automation source-control sync-job create requires the stop-parsing operator so the empty --commit-id "" survives the shell.Section takeaways - five operational principles that compound together
Section 6
ARM Templates, Bicep, and the Azure Developer CLI
Initialize azd-maester with azd init -t nathanmcnulty/azd-maester, move into the function-app solution, deploy it with azd up, re-run azd up with additional options to show that the deployment is idempotent, inspect the Bicep files and azd hook outputs that drove the deployment, and then cleanly remove the environment with azd down.
az --version)azd version)azd auth login in the training tenantAzure Developer CLI:
Initialize from the template, move into the solution folder, and sign in
azd init -t nathanmcnulty/azd-maester Set-Location .\azd-maester\function-app azd env new lab-maester-func azd auth login
If azd init prompts for an environment name at the repository root, enter any disposable placeholder such as lab-maester-root. The deployable function-app folder keeps its own local azd environment, so azd env new lab-maester-func is the environment that the rest of this lab uses.
The repository root is intentionally not deployable. If you run azd up from the root, the guard script stops the run and tells you to choose a solution folder. Stay inside function-app for the rest of the lab.
Expected outcomes:
- The template is cloned locally into .\azd-maester
- The deployable function-app folder now has a selected local azd environment named lab-maester-func
- You are authenticated to Azure Developer CLI for the current tenant
- Your shell is now scoped to the deployable function-app solution
Azure Developer CLI:
Run the interactive quickstart deployment
azd up # Review the environment values and generated artifacts after deployment finishes. azd env get-values Get-ChildItem .\outputs
NoNoNoNoExpected outcomes:
- azd up runs the solution's pre-up, pre-provision, provision, post-provision, and deploy workflow from the function-app folder
- azd env get-values shows the environment-scoped resource names and identifiers
- The outputs folder contains the setup summary produced by the solution hooks
This task demonstrates idempotent deployment behavior. Re-run azd up in the same environment, keep the existing Function App resources, and add only the optional Web App and Azure RBAC pieces.
Azure Developer CLI and Azure CLI:
Prepare the rerun inputs and execute the second deployment
$resourceGroupName = (azd env get-values | Select-String '^AZURE_RESOURCE_GROUP=').Line.Split('=')[1].Trim('"') $resourceGroupId = az group show --name $resourceGroupName --query id -o tsv $securityGroupObjectId = "<lab-security-group-object-id>" azd env set AZURE_RBAC_SCOPES $resourceGroupId azd up
Yes$securityGroupObjectIdNoNoYes$resourceGroupId if promptedExpected outcomes:
- The second azd up is idempotent: unchanged Function App resources stay in place while the optional Web App and Azure RBAC settings are added
- The environment now carries AZURE_RBAC_SCOPES for future reruns and pipeline use
- The rerun proves that azd updates the declared environment instead of creating a second parallel stack
This solution is additive across reruns. Turning an include from No to Yes adds the new resources and assignments without breaking the existing deployment. Turning an include back to No later does not revoke previously granted access; cleanup happens during azd down.
Native PowerShell:
Inspect the Bicep files and timer trigger that drove the deployment
Get-ChildItem .\infra -Recurse -Filter *.bicep | Select-Object FullName Get-Content .\infra\main.bicep Get-Content .\src\MaesterTimerTrigger\function.json
AZURE_RESOURCE_GROUP.Functions, Identity, and the deployed app settings.Expected outcomes:
- The resource group contents line up with the Bicep file structure
- The Function App uses managed identity rather than a stored secret or certificate
- The timer trigger schedule in function.json explains when Maester runs after deployment
Native PowerShell:
Inspect the scripts and generated files that azd used around the deployment
Get-ChildItem .\scripts | Select-Object Name Get-ChildItem .\outputs | Select-Object Name Get-Content .\scripts\Invoke-FunctionValidation.ps1 -TotalCount 80
Expected outcomes:
- You can identify the hook and deployment script files that azd calls before and after provisioning
- The validation script shows how the project checks the Function App after deployment
- The generated setup summary in outputs\<env>-setup-summary.md gives you a readable record of what the solution configured
Azure Developer CLI:
Remove the Azure resources and the local azd environment state
$environmentName = "lab-maester-func" azd down -e $environmentName --force --purge azd env remove $environmentName --force
Expected outcomes:
- The resource group and Function App resources are deleted cleanly
- The solution's best-effort cleanup hooks run before resource deletion
- The local azd environment state is removed after azd env remove
function-app or the guard script will stop you.azd auth login is skipped or expires, azd up cannot finish the wizard or deployment.No. Turning them on requires extra values or additional privileges.azd up with different include flags is additive. It does not automatically revoke earlier permissions or delete optional resources you already created.azd down removes Azure resources, but azd env remove deletes the local environment record separately.centralus, which was the validated classroom region.Course key takeaways: the five principles to apply directly in production.