Training module · Draft
Module 6: PIM for Azure resources
Status — Draft
This page is still being written. Expect gaps and changes; it is not part of the workbook's production guidance. Last reviewed 2026-11-07.
Part of the Privileged Access learning path. Plan Phase 3, task A3.2. Workbook Chapter 06. About 50 minutes.
Watch: the narrated deck for this module: Deck · ▶ Watch (about 13 minutes, captions) · MP4 · Script · Captions.
Introduction (3 minutes)
Owner, Contributor and User Access Administrator on a subscription are privileged too. The plan’s A3.2 makes them eligible through PIM, with at most three subscription owners. PIM for Azure resources isn’t in Microsoft Graph: it goes through Azure Resource Manager, with Azure roles instead of Graph permissions.
In this module you hold PIM for Azure resources as code. One reviewed file says which scopes are governed, the settings for each tier, who is eligible, and which standing assignments are allowed and who owns each one. The code reads the estate, prints a diff, writes the requests it would send, and sends them only with -Apply.
- Eligible first. Each standing Owner, Contributor or User Access Administrator held by a user or group gets an eligible replacement before its removal, so no holder loses the role before they can activate it.
- PA-AZ-05 flags a standing assignment held by the identity the plan runs as: the apply needs it to finish, so decide it last, from another session. Design guidance.
- The desired state refuses an authentication context before its Conditional Access policy is confirmed, as Learn advises.
- Policies go first, because a request that breaks a policy is refused.
The federal requirement. The desired state, the plan and the read-back are the evidence for the plan’s task A3.2, “Azure privileged roles eligible through PIM.” They support NIST SP 800-53 AC-6, AC-6(1) and AC-6(5), AC-2, and CM-3 with CM-4(2). BOD 25-01’s Microsoft Entra ID policies cover Entra roles, not Azure roles, so the chapter claims none here. The full table is the chapter’s Federal requirements and evidence.
| Requirement | KSI | Evidence |
|---|---|---|
| AC-6 and AC-6(1): each tier’s role settings | KSI-IAM-JIT | The ~ lines in diff.txt against desired.json |
| AC-6, AC-6(5) and AC-2: standing privilege, service principals, root-scope elevation, undeclared eligibility, too many Owners | KSI-IAM-JIT, KSI-IAM-ELP | PA-AZ-01 to 06 in findings.json |
| CM-3: the change reviewed before it’s made, with the back-out | KSI-CMT-LMC, KSI-CMT-RMV | state.json, plan.json and diff.txt, with manifest.json |
| CM-3 and CM-4(2): policies, then eligibility, then removals, and nothing left after | KSI-CMT-LMC, KSI-CMT-VTD, KSI-IAM-JIT | diff-after.txt from the apply |
| AC-6, least privilege for each identity | KSI-IAM-ELP | Reader to plan; User Access Administrator to apply; a read client refuses a write |
KSIs are FedRAMP 20x indicators for cloud service providers. The agency authorizes its own system and lines this evidence up with what its providers report against them; it doesn’t report KSIs itself (Book 00, Appendix B).
Learning objectives (1 minute)
After this module, you’ll be able to:
- explain why PIM for Azure resources goes through Resource Manager, and which API version the code uses;
- explain why role settings must be set at every governed scope;
- say who can and can’t be made eligible;
- read the plan’s three kinds of operation and its findings, PA-AZ-01 to PA-AZ-06;
- explain why the code declares User Access Administrator, not Owner, for the apply.
Prerequisites (1 minute)
Unit 1: Resource Manager, scopes and holders (8 minutes)
Read the chapter’s The context the code depends on. Note:
- Resource Manager, not Graph. Three resource types under
Microsoft.Authorizationdo the work. Learn marks2020-10-01as the generally available release of every PIM resource type; the code pins it. - Policies don’t inherit. Settings at a subscription aren’t inherited by a resource group under it, so the file lists every scope it governs.
- Who can be eligible. Users and groups, not applications, service principals or managed identities, which can’t perform the activation steps. A standing Contributor held by a deployment pipeline is reported and left alone.
- What counts as privileged. Learn’s privileged administrator roles; the chapter governs the three the plan names. Microsoft’s best practice is at most three subscription owners.
Unit 2: The plan and the apply (8 minutes)
Read What it adds. Note:
- The call list has two tasks. Reader reads; User Access Administrator applies. The code follows the stricter Learn statement and declares User Access Administrator, the narrower of the two roles the PIM pages name.
- The plan’s order: policy updates, then new eligible assignments, then removal of standing assignments. Each request has a GUID derived from the run ID and the operation, so the dry run and the apply send exactly the requests the reviewer read.
- An eligibility the file doesn’t declare is reported (PA-AZ-04), never removed by the plan.
- Every run writes
state.json, which holds each policy’s rules as read: that’s the back-out.
Exercise: Lab 1, fixtures, no tenant (25 minutes)
This is the workbook’s Lab 1. Work in examples/privileged-access, in one PowerShell 7 session. The fixture estate has a management group (contoso-root), a workload subscription, a control-plane subscription and a resource group, rg-payroll. The identity that would run the apply is the fictional service principal sp-pim-automation, c3000000-0000-4000-8000-000000000003.
Every command in this lab, in order, from examples/privileged-access. Each step below says what it does and what to look for.
# Step 1: Replay Microsoft's examples
Invoke-Pester -Path tests/AzurePim.Tests.ps1 -FullNameFilter "*Microsoft's examples*" -Output None -PassThru |
Select-Object PassedCount, FailedCount, NotRunCount
# Step 2: List the roles each task needs
Import-Module ./powershell/PaAzurePim/PaAzurePim.psm1
(Get-PaAzRequiredRole -Task azure-pim-read).AzureRoles -join ', '
(Get-PaAzRequiredRole -Task azure-pim-apply).AzureRoles -join ', '
# Step 3: Plan, as a dry run
./Invoke-PaAzurePim.ps1 -Cloud gcc -FixturePath tests/fixtures/ch06/estate.json -DesiredPath tests/fixtures/ch06/desired.json `
-CallerPrincipalId c3000000-0000-4000-8000-000000000003 -OutputPath out/ch06 -RunId lab
# Step 4: Read the diff and the findings
Get-Content out/ch06/diff.txt | Where-Object { $_ -match '^[+-] ' }
(Get-Content out/ch06/diff.txt | Where-Object { $_ -match '^~ ' }).Count
Get-Content out/ch06/findings.json -Raw | ConvertFrom-Json | Where-Object Severity -ne 'Warn' | Format-Table Rule, Severity, Role, Scope
# Step 5: Apply against the fake
./Invoke-PaAzurePim.ps1 -Cloud gcc -FixturePath tests/fixtures/ch06/estate.json -DesiredPath tests/fixtures/ch06/desired.json `
-CallerPrincipalId c3000000-0000-4000-8000-000000000003 -OutputPath out/ch06-apply -RunId lab -Apply
# Step 6: See the code refuse
$read = New-PaAzClient -Cloud gcc -Task azure-pim-read -Invoke (New-PaAzFixtureInvoker -Path tests/fixtures/ch06/estate.json).Invoke
Invoke-PaAzWrite -Client $read -CallId azpim-policy-update -Bind @{ scope = '/subscriptions/11111111-1111-4111-8111-111111111111'; name = 'f6000000-0000-4000-8000-000000000004' } -Body @{} -Apply
ConvertTo-PaAzScope '/subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll/providers/Microsoft.Web/sites/payroll'
# Step 7: Run the tests, then delete out
Invoke-Pester -Path tests
Remove-Item -Recurse -Force outReplay Microsoft’s examples:
Invoke-Pester -Path tests/AzurePim.Tests.ps1 -FullNameFilter "*Microsoft's examples*" -Output None -PassThru | Select-Object PassedCount, FailedCount, NotRunCountPassedCount FailedCount NotRunCount ----------- ----------- ----------- 6 0 32The second test shows Microsoft’s own assignment example is an activation, though its
assignmentTypesaysAssigned.List the roles each task needs:
Import-Module ./powershell/PaAzurePim/PaAzurePim.psm1 (Get-PaAzRequiredRole -Task azure-pim-read).AzureRoles -join ', ' (Get-PaAzRequiredRole -Task azure-pim-apply).AzureRoles -join ', 'Reader Reader, User Access AdministratorOwner isn’t needed for anything.
Plan, as a dry run:
./Invoke-PaAzurePim.ps1 -Cloud gcc -FixturePath tests/fixtures/ch06/estate.json -DesiredPath tests/fixtures/ch06/desired.json ` -CallerPrincipalId c3000000-0000-4000-8000-000000000003 -OutputPath out/ch06 -RunId labRead: 9 assignment and 2 eligibility schedules, 12 policies at 4 scopes Plan: 11 policy updates, 5 eligible assignments to add, 3 standing assignments to remove Findings: 7 Finding, 2 Warn, 1 Info Dry run: 19 requests written to plan.json, none sent. Review diff.txt, then rerun with -Apply. Written to out/ch06 (manifest.json lists every request and file hash)Read the diff and the findings:
Get-Content out/ch06/diff.txt | Where-Object { $_ -match '^[+-] ' } (Get-Content out/ch06/diff.txt | Where-Object { $_ -match '^~ ' }).Count Get-Content out/ch06/findings.json -Raw | ConvertFrom-Json | Where-Object Severity -ne 'Warn' | Format-Table Rule, Severity, Role, Scope+ eligible User Access Administrator at /providers/Microsoft.Management/managementGroups/contoso-root (T0) for b2000000-0000-4000-8000-000000000001, P90D (converted) + eligible Owner at /subscriptions/11111111-1111-4111-8111-111111111111 (T1) for a1000000-0000-4000-8000-000000000001, P180D (converted) + eligible Owner at /subscriptions/11111111-1111-4111-8111-111111111111 (T1) for b2000000-0000-4000-8000-000000000002, P180D + eligible Contributor at /subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll (T1) for a1000000-0000-4000-8000-000000000002, P180D (converted) + eligible Contributor at /subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll (T1) for b2000000-0000-4000-8000-000000000003, P180D - active User Access Administrator at /providers/Microsoft.Management/managementGroups/contoso-root (T0) for b2000000-0000-4000-8000-000000000001 (standing; eligible replacement first) - active Owner at /subscriptions/11111111-1111-4111-8111-111111111111 (T1) for a1000000-0000-4000-8000-000000000001 (standing; eligible replacement first) - active Contributor at /subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll (T1) for a1000000-0000-4000-8000-000000000002 (standing; eligible replacement first) 70 Rule Severity Role Scope ---- -------- ---- ----- PA-AZ-01 Finding User Access Administrator /providers/Microsoft.Management/managementGroups/contoso-root PA-AZ-05 Finding User Access Administrator /providers/Microsoft.Management/managementGroups/contoso-root PA-AZ-03 Finding User Access Administrator / PA-AZ-01 Finding Owner /subscriptions/11111111-1111-4111-8111-111111111111 PA-AZ-02 Finding Contributor /subscriptions/11111111-1111-4111-8111-111111111111 PA-AZ-01 Info Contributor /subscriptions/22222222-2222-4222-8222-222222222222 PA-AZ-01 Finding Contributor /subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll PA-AZ-06 Finding Owner /subscriptions/11111111-1111-4111-8111-111111111111Each
(converted)line has its- activeline: eligible replacement first. The 70~lines are policy rule changes. PA-AZ-05 issp-pim-automation’s own User Access Administrator; PA-AZ-02 is a pipeline’s standing Contributor, which can’t be made eligible. The chapter’s step 4 explains each row.Apply against the fake:
./Invoke-PaAzurePim.ps1 -Cloud gcc -FixturePath tests/fixtures/ch06/estate.json -DesiredPath tests/fixtures/ch06/desired.json ` -CallerPrincipalId c3000000-0000-4000-8000-000000000003 -OutputPath out/ch06-apply -RunId lab -ApplyRead: 9 assignment and 2 eligibility schedules, 12 policies at 4 scopes Plan: 11 policy updates, 5 eligible assignments to add, 3 standing assignments to remove Findings: 7 Finding, 2 Warn, 1 Info Applied: 19 requests sent; left to change after a fresh read: 0 Written to out/ch06-apply (manifest.json lists every request and file hash)See the code refuse:
$read = New-PaAzClient -Cloud gcc -Task azure-pim-read -Invoke (New-PaAzFixtureInvoker -Path tests/fixtures/ch06/estate.json).Invoke Invoke-PaAzWrite -Client $read -CallId azpim-policy-update -Bind @{ scope = '/subscriptions/11111111-1111-4111-8111-111111111111'; name = 'f6000000-0000-4000-8000-000000000004' } -Body @{} -Apply ConvertTo-PaAzScope '/subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll/providers/Microsoft.Web/sites/payroll'Exception: Undeclared call 'azpim-policy-update' for task 'azure-pim-read'; add it to config/calls-ch06.json with its role. Exception: '/subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-payroll/providers/Microsoft.Web/sites/payroll' isn't a management group, subscription or resource group scopeRun the tests, then delete
out:Invoke-Pester -Path tests Remove-Item -Recurse -Force outTests Passed: 330, Failed: 0.
Exercise: Lab 2, your test tenant (5 minutes to read)
Not run by this path’s authors. Follow the workbook’s Lab 2 in a test tenant in the worldwide or GCC cloud, with a test management group, subscription and resource group, never production, and fictional users and groups. Eligible users need Microsoft Entra ID P2 or Microsoft Entra ID Governance.
- What you do: prepare an app with Reader on each test scope, write the desired state for the test scopes, plan live, apply under a one-day User Access Administrator assignment, and try the narrower role on one scope.
- Permissions: no Graph permission; Reader on each test scope to plan; User Access Administrator, time-bound for one day, to apply.
- Back-out: step 6 waits at least five minutes after the last assignment, removes the eligible assignments the run created, restores each policy from the dry run’s
state.json, removes the app’s roles and the app, and deletes theout/ch06-test-*folders.
Knowledge check (5 minutes)
- Which Graph permissions does PIM for Azure resources need?
RoleManagement.ReadWrite.Directory.- None: it goes through Resource Manager, and the caller needs an Azure role on the resource.
RoleManagementPolicy.ReadWrite.Directory.Policy.ReadWrite.ConditionalAccess.
- You set role settings at a subscription. Does a resource group under it get them?
- Yes, always.
- No: Learn says settings aren’t inherited, so the file lists every governed scope.
- Only for Owner.
- Only after 24 hours.
- A deployment pipeline’s service principal holds standing Contributor. What does the plan do?
- Converts it to eligible.
- Removes it.
- Reports PA-AZ-02 and leaves it alone, because service principals can’t be made eligible.
- Adds it to the exceptions list.
- Why does the plan add each eligible replacement before removing the standing assignment?
- Resource Manager requires it.
- So no holder loses the role before they can activate it.
- To keep the Owner count under three.
- Removals are slower.
- b. Taught in The context the code depends on.
- b. Taught in the same section, under “Policies don’t inherit.”
- c. Taught in What it adds (PA-AZ-02), and seen in Lab 1, step 4.
- b. Taught in What it adds, step 3 of the plan.
Summary (2 minutes)
You read a fictional Azure estate at four scopes, planned 19 requests in order (policies, eligibility, removals), saw each conversion add the eligible assignment first, and applied them to the fake with nothing left to change. You saw a read client refuse a write.
The evidence an ISSO keeps: the reviewed desired.json; each run’s state.json, plan.json, diff.txt and findings.json with its manifest; and the apply’s diff-after.txt. The full list is in the chapter’s Federal requirements and evidence; before the code ships, work through its checklist.