Back to Home
Securing Applications and Automation - Training Series
All guided labs in one workbook
Guided Lab Workbook

Guided Labs Across the Training Series

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.


Source
website/section-*.html
Refresh
pwsh ./v3/generate_guided_lab.ps1
Sections
6 guided labs
Audience
Infrastructure and security administrators

Section map

Section 1 Understanding Modern Service Principals Microsoft Entra Applications, Managed Identities, and Workload Authentication Section 2 Permissions and Scopes OAuth Permissions, Admin Consent, and Security Attacks Section 3 Tokens and APIs Token Acquisition, IMDS, Microsoft Graph, and Azure Resource Manager Section 4 Automation Tools Automation Accounts, Logic Apps, Function Apps, and CI/CD Section 5 Maintenance and Management Source Control, Observability, AI-Assisted Development, and Service Principal Hygiene Section 6 Packaging Tools and azd ARM Templates, Bicep, and the Azure Developer CLI

Section 1

Understanding Modern Service Principals

Microsoft Entra Applications, Managed Identities, and Workload Authentication

Open source section

Lab goal

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.

Step 1: Sign in and get an admin Microsoft Graph token

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:

Azure CLI:

$tenantId = "<tenant-id>"

az login --tenant $tenantId

$accessToken = az account get-access-token `
   --resource-type ms-graph `
   --tenant $tenantId `
   --query accessToken `
   -o tsv

$accessToken

Azure PowerShell:

$tenantId = "<tenant-id>"

Connect-AzAccount -Tenant $tenantId

$accessToken = Get-AzAccessToken -ResourceTypeName MSGraph -Tenant $tenantId
$accessToken = $accessToken.Token | ConvertFrom-SecureString -AsPlainText

$accessToken
⚠️Access tokens written to console

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).

Step 2: Register the app

Entra Portal:

Register the app
  1. Go to Entra ID -> App registrations.
  2. Select New registration.
  3. Name the app section1-GraphReader-<name>.
  4. Set supported account types to Single tenant only.
  5. Select Register.

Record:

Open the managed application
  1. In the app registration, select Managed application in local directory.
  2. Record $spObjectId as the enterprise application / service principal object ID.

You should now have $clientId, $tenantId, $appObjectId, and $spObjectId

Native PowerShell:

$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
}

Graph PowerShell:

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
}

Azure CLI:

$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
}

Step 3: Create a client secret

Entra Portal:

Create a client secret
  1. Open Certificates & secrets.
  2. Select New client secret.
  3. Copy the secret value when it is displayed.

Native PowerShell:

# 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

Graph PowerShell:

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

Azure CLI:

# 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
⚠️Risk - Secret + Broad Permissions = Legacy Weakness

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:

Step 4: Add Microsoft Graph application permission

Entra Portal:

  1. Open API permissions
  2. Add Microsoft Graph
  3. Choose Application permissions
  4. Search and add User.Read.All
  5. Grant admin consent

Native PowerShell:

# 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

Graph PowerShell:

# 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

Azure CLI:

# 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

Step 5: Request the token

Native PowerShell:

# 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

Graph PowerShell:

# 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

Azure CLI:

# 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

Step 6: Decode the token

$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:

Step 7: Call Microsoft Graph

$headers = @{ Authorization = "Bearer $accessToken" }
$response = Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/users" `
  -Headers $headers

$response

$response.value

Step 8: Review the sign-in evidence

Open service principal sign-ins in the Entra admin center and look for the app-only sign-in to Microsoft Graph.

  1. Go to Entra IDMonitoring & healthSign-in logs.
  2. Switch to the Service principal sign-ins tab.
  3. Find the sign-in for your app - look for the application name and a resource of Microsoft Graph.
  4. Inspect the details: note the IP address, location, and the authentication method used (client secret).
💡Pro Tip - Sign-in Logs Are Your First Stop

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

Permissions and Scopes

OAuth Permissions, Admin Consent, and Security Attacks

Open source section

Lab goal

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.

Step 1: Sign in and get an admin Microsoft Graph token

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:

Azure CLI:

$tenantId = "<tenant-id>"

az login --tenant $tenantId

$accessToken = az account get-access-token `
   --resource-type ms-graph `
   --tenant $tenantId `
   --query accessToken `
   -o tsv

$accessToken

Azure PowerShell:

$tenantId = "<tenant-id>"

Connect-AzAccount -Tenant $tenantId

$accessToken = Get-AzAccessToken -ResourceTypeName MSGraph -Tenant $tenantId
$accessToken = $accessToken.Token | ConvertFrom-SecureString -AsPlainText

$accessToken
⚠️Access tokens written to console

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).

Step 2: Prepare a disposable demo app and grants

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.

Native PowerShell:

# 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
}

Graph PowerShell:

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"
  }

Azure CLI:

$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 }

Step 3: Sign in to Graph Explorer and review the consent prompt

Entra ID / Graph Explorer:

Review the delegated consent request
  1. Open https://developer.microsoft.com/en-us/graph/graph-explorer in a browser.
  2. Select Sign in to Graph Explorer or click the user image in the top right to sign in.
  3. Review the consent prompt: note the app name, publisher, and listed delegated permissions.
  4. Accept the consent but do not consent for the organization.
⚠️ If you do not see a consent dialog

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.

Step 4: Run delegated Graph calls

Graph Explorer:

Call /me
  1. In Graph Explorer, run GET https://graph.microsoft.com/v1.0/me.

Expected: returns your own user object. This is delegated access.

Call /users and expand scope
  1. Run GET 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.

  1. Click Modify Permissions, then click Consent and accept the consent but do not consent for the organization.
  2. Run GET https://graph.microsoft.com/v1.0/users.

Native PowerShell:

# 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

Graph PowerShell:

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

Azure CLI:

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.

Step 5: Confirm the consent grant in Entra

Entra Portal:

Review the enterprise app permissions
  1. Go to the Entra admin center > Enterprise Applications.
  2. Search for Graph Explorer to review the delegated grant created in Step 3.
  3. If you did the Native PowerShell option, search for the disposable Section2-PermissionsDemo-* enterprise app from Step 2 and open Permissions.
  4. Review the delegated grant on the disposable demo app so the code paths below target the same object.

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.

Native PowerShell:

Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" `
  -Headers @{ Authorization = "Bearer $accessToken" }

Graph PowerShell:

Get-MgOauth2PermissionGrant -Filter "clientId eq '$demoSpObjectId'" |
  Select-Object Id, Scope, ConsentType, PrincipalId

Azure CLI:

$grants = az ad app permission list-grants `
  --id $demoApp.appId `
  --output json | ConvertFrom-Json

$grants | Select-Object id, scope, consentType, principalId

Step 6: Enumerate and revoke a delegated permission grant

Entra Portal:

Refresh the permissions view after revocation
  1. Keep the disposable demo app Permissions blade open from Step 5.
  2. After you run one of the revocation commands below, refresh the page and verify that the delegated grant disappears.

Native PowerShell:

# 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" }

Graph PowerShell:

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

Azure CLI:

# Revoke a specific delegated grant.
$grantId = $demoDelegatedGrant.id
az rest `
  --method delete `
  --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$grantId"
💡Pro Tip - Revocation Sequence Matters

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.

Step 7: Review App Governance for overprivileged apps

Defender Portal:

Filter for high-privilege application permissions
  1. Open Microsoft Defender XDR.
  2. Navigate to Cloud Apps > App Governance.
  3. Filter to apps with Mail.ReadWrite, Files.ReadWrite.All, Application.ReadWrite.All, or any other application permissions you want to look at.
  4. Review the last-used date, owner status, and permission level.

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.

Step 8: Verify Conditional Access blocking device code flow

Entra Portal:

Review or create a report-only policy
  1. Go to Entra ID > Conditional Access > Policies.
  2. Locate or create a policy that targets all users and blocks the device code authentication flow.
  3. Ensure the policy excludes your break-glass or admin account before enabling it.
  4. Confirm the policy is set to 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.

Native PowerShell:

# 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

Graph PowerShell:

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

Azure CLI:

$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

Step 9: Clean up the disposable demo objects

Native PowerShell:

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

Graph PowerShell:

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

Azure CLI:

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

Tokens and APIs

Token Acquisition, IMDS, Microsoft Graph, and Azure Resource Manager

Open source section

Lab goal

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.

Lab prerequisites

Step 1: Create an Automation Account for this lab

Create 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"

Step 2: Grant the managed identity the Graph permission the lab expects

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.

Azure Portal:

Confirm the managed identity principal ID
  1. Open the Automation Account.
  2. Go to Identity and confirm System assigned is On.
  3. Copy the Object (principal) ID so you can verify the same service principal in Entra after the grant.

Native PowerShell:

$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

Graph PowerShell:

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
    }
}

Azure CLI:

# 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`"}"

Step 3: Connect using managed identity and call Graph through Graph PowerShell

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.

Step 4: Acquire a raw token and call Graph via REST

Azure Automation runbook:

Get the Graph token explicitly and call /users
Disable-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.

Step 5: Decode the JWT and inspect claims

Local PowerShell:

Use $token = Get-Clipboard to 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:

Paste the token into https://jwt.ms if you want to verify the same claims visually in the browser.

Step 6: Implement paginated Graph calls

Local PowerShell or Update Azure Automation runbook:

Follow @odata.nextLink until 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.

Step 7: Add field selection to reduce response size

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 1 IMDS = credential-free foundation No secrets to rotate, no creds to leak 2 Protect IMDS access, use least-privileged roles Metadata header, proxy bypass, local exposure control 3 Decode token first - never guess 5 minutes with JWT vs. hours waiting for L2 4 Always paginate Graph collection calls Missing loop = silent data loss 5 ARM RBAC ≠ Graph consent Two planes - diagnose separately These five rules resolve 90% of managed identity issues

Admin takeaways: 5 principles to remember from this section

Section 4

Automation Tools

Automation Accounts, Logic Apps, Function Apps, and CI/CD

Open source section

Lab goal

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.

Prerequisites

Task 1: Configure WIF for a GitHub Actions workflow

Log 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 Portal:

Create the app registration and federated credential
  1. Go to Entra ID > App registrations > New registration.
  2. Name the app Section4-GitHubWIF-<initials> and choose Single tenant only, then click Register.
  3. After the app is created, go to Certificates & secrets > Federated credentials.
  4. Choose GitHub Actions deploying Azure resources, set your GitHub owner and repository, and scope the subject to the main branch.
  5. Go to API permissions > Add a permission > Microsoft Graph > Application permissions, and add User.Read.All.
  6. Record the application (client) ID and tenant ID for GitHub Actions variables.

Graph PowerShell:

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
  }

Azure CLI:

$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

GitHub Actions:

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

Task 2: Function App with managed identity calling Key Vault and Blob Storage

Azure Portal:

Create the Key Vault secret, blob container, and Function App role assignments
  1. Create a standard-tier Key Vault, a StorageV2 account, and a Flex Consumption Function App in the same region. Flex Consumption is Linux-only, so do not reuse older Windows Consumption examples for this task.
  2. Enable the Function App system-assigned managed identity if it was not created during deployment.
  3. Because the vault uses RBAC, grant yourself Key Vault Secrets Officer or Key Vault Administrator before you create the first secret.
  4. Grant yourself 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.
  5. In the Key Vault, create a secret named DemoSecret with the value SuperSecretValue123.
  6. In Blob Storage, create a container named names and upload names.txt with a few names, one per line.
  7. Grant the Function App Managed Identity Key Vault Secrets User on the vault and Storage Blob Data Contributor on the storage account.

Azure CLI:

$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

PowerShell:

Create the local Function project files
$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

Azure CLI:

Configure app settings, deploy the package, and invoke the function
$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

Common pitfalls

Admin takeaways

1 Migrate Run As Accounts Audit every Automation Account - retired Sep 2023 2 WIF for CI/CD - no secrets Scope subject claims to repo+branch or environment 3 Audit Logic App connections Replace user OAuth tokens with managed identity 4 Network controls required vNet integration + Private Endpoints for automation 5 Event-driven over scheduled polling Match trigger to event - polling is a last resort Section 4 key principles

Section takeaways - five principles for automation tool selection and hardening

Section 5

Maintenance and Management

Source Control, Observability, AI-Assisted Development, and Service Principal Hygiene

Open source section

Lab goal

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.

Prerequisites

Task 1: Source control sync for Automation Account

Azure Portal:

Create the source control connection
  1. Open the Automation Account.
  2. Go to Account Settings -> Source control and click Add to add a new connection.
  3. Provide a name, select GitHub as the Source control type, authenticate to GitHub, then select your repository, the main branch, and the /runbooks folder.
  4. In your GitHub repository, click Add file -> Create new file. For the file name, use /runbooks/hello-world.ps1 which will create the /runbooks folder for you.
  5. Back in Azure, select your GitHub source, then click 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

Task 2: PSScriptAnalyzer on a runbook

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

Task 3: Graph API credential expiry query

Graph PowerShell:

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

Task 4: Log Analytics job diagnostics

Azure Portal:

Enable the Automation diagnostic categories you actually need
  1. Open the Automation Account.
  2. Go to Diagnostic settings and add a setting.
  3. Enable JobLogs, JobStreams, and AuditEvent.
  4. Send them to the Log Analytics workspace.

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

Task 5: Monitor alert for failed runbook

Azure Portal:

Create the alert from the Monitor blade
  1. Go to Monitor > Alerts > Create.
  2. Use the Log Analytics workspace or the Automation Account as the scope, depending on whether you want a query-based or metric-based alert. Example:
    AzureDiagnostics 
    | where ResourceProvider == 'MICROSOFT.AUTOMATION' 
    | where Category == 'JobLogs' 
    | where ResultType == 'Failed' 
    | where TimeGenerated > ago(15m)"
  3. Keep the alert disabled until the first successful query proves your data shape.

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.

Common pitfalls

Admin takeaways

5 Operational Principles Source control is mandatory Portal edit = ungoverned · use Git → CI/CD → deploy AI code has predictable weaknesses Hardcoded secrets · overprivileged APIs · missing error handling Static analysis in CI is free insurance PSScriptAnalyzer · Bandit · zero meaningful time cost Silent failure is operationally dangerous Alert on threshold > 0 failures · configure before production Every SP needs name, owner, expiry monitoring Orphanless · purposeful · monitored credential lifecycle These practices compound - skip one, and others stop working

Section takeaways - five operational principles that compound together

Section 6

Packaging Tools and azd

ARM Templates, Bicep, and the Azure Developer CLI

Open source section

Lab goal

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.

Prerequisites

Task 1: Initialize azd-maester and enter the function-app solution

Azure 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.

🧭Multi-solution repo note

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

Task 2: Deploy the function-app solution with azd up

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

Recommended wizard responses for the first pass

  1. Azure subscription: choose the lab subscription
  2. Resource group: use the prepared lab resource group, or create a new one if your environment requires it
  3. Include Web App: No
  4. Include Exchange: No
  5. Include Teams: No
  6. Include Azure: No
  7. Optional mail recipient: leave blank

Expected 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

Task 3: Re-run azd up to add the Web App and Azure access

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

Recommended rerun responses

  1. Include Web App: Yes
  2. Security group object ID: paste $securityGroupObjectId
  3. Include Exchange: No
  4. Include Teams: No
  5. Include Azure: Yes
  6. Azure RBAC scopes: use $resourceGroupId if prompted
  7. Optional mail recipient: leave blank

Expected 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

🔁Idempotence Note

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.

Task 4: Inspect the Bicep and function files

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 Portal:

Match the deployed resources to the Bicep definitions
  1. Open the deployed resource group from AZURE_RESOURCE_GROUP.
  2. Open the Function App and review Functions, Identity, and the deployed app settings.
  3. Open the Storage Account and confirm it matches the supporting resources declared in Bicep.
  4. Compare the deployed resources with the Bicep resources, outputs, and timer schedule.

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

Task 5: Review the azd hooks and validation outputs

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

Task 6: Tear down the environment

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

Common pitfalls

Admin takeaways

Key Principles 1. No client secrets for Azure workloads managed identity and WIF replace static credentials 2. ARM is the foundation layer portal, CLI, PowerShell, and SDKs all call management.azure.com 3. Bicep beats raw ARM JSON for authoring same engine, better readability, and IntelliSense guidance 4. azd up makes solutions portable repeatable provisioning, code deployment, and CI/CD setup 5. Key Vault can be a credential store treat private keys, tokens, and secrets with the same controls Apply these patterns directly to production environments

Course key takeaways: the five principles to apply directly in production.