Privileged access.
Practiced end to end.
A hands-on lab for Microsoft Entra Privileged Identity Management (PIM): portal procedures, matching PowerShell and REST automation, negative tests, audit evidence, and deliberate teardown.
Before you touch a tenant
Suggested pace: two half-day sessions, plus asynchronous expiry/review observations. Read each module's checkpoint before running it. Run the portal task or its automation equivalent for creation; do not create the same assignment twice. Use the other path to inspect the result.
Coverage: the three PIM planes; discovery; eligible/active and permanent/time-bound assignments; activation, approval, denial, cancellation, deactivation, extension and renewal; MFA, ticketing, justification, notifications, authentication context; membership and ownership; role bundles; access reviews; alerts; audit/export; scoped/custom roles, ABAC, custom activation extensions; cleanup. Preview APIs and optional adjacent integrations are explicitly marked. This is a core human-administrator lab, not a claim that every preview, national cloud, or workload implements identical capabilities.
Portal labels can vary by rollout: "ID Governance" may appear as "Identity governance." Search the portal for "Privileged Identity Management" if the navigation differs. Commercial-cloud endpoints are used throughout. Sources are linked next to the relevant exercises; open them for cloud-specific availability and current permissions.
Prepare identities and safety boundaries
Goal: separate administration, requesting, approval, and observation. Record object IDs, not just display names. An Entra administrator does not automatically administer an Azure subscription.
| Lab identity / asset | Purpose and prerequisite |
|---|---|
| pim-admin | Existing test administrator with an active Privileged Role Administrator (PRA) assignment. Bootstrap consent may need a separate authorized tenant consent administrator. Do not make the only recovery administrator eligible. |
| pim-operator | Licensed ordinary member user, no standing directory role and no pre-existing lab Azure permissions. This user requests elevation. |
| pim-approver | Different licensed member user with a working mailbox if testing email. Specified as approver; no broad admin role is needed merely to approve a request. |
| pim-auditor (optional) | Security Reader for directory evidence; separately grant Azure Reader at the lab scope if Azure visibility is required. |
| Recovery accounts | Maintain the tenant's established emergency-access arrangements outside the lab. Verify they work. Never use their IDs in scripts or review scopes. |
| rg-pim-lab | New, empty resource group. Resource administration performed by a lab subscription Owner. No VMs or paid services are required for the main exercises. |
| PIM-Lab-Readers | New cloud-only, assigned-membership security group, optionally role-assignable for module 10. Keep an existing active lab owner on it. |
Portal walkthrough
- Open Microsoft Entra admin center. Confirm the tenant name and tenant ID under Entra ID > Overview. Use a tenant you are authorized to administer.
- Under Entra ID > Licenses > All products (or the linked Microsoft 365 licensing experience), verify the applicable P2 or ID Governance entitlement and assign appropriate licenses to the eligible users, approvers, and reviewers according to the licensing reference. A subscription alone is not a user license. Trials expire; record their end date.
- Under Entra ID > Users > All users, select existing disposable users or create the named lab accounts using New user > Create new user. Record each Object ID. Complete first sign-in and MFA enrollment as each user. Never put passwords in your lab notes.
- Give only the designated admin the required test roles. Verify PRA is active before managing directory PIM. In a separate browser profile, ensure the operator has no directory roles. Use distinct profiles for admin, operator, and approver; private windows can share cookies.
- Open Azure portal. Confirm the directory and subscription under the directory/subscription filter. As the subscription Owner, create Resource groups > Create > rg-pim-lab in an approved region. Add tags
Purpose=PIMLabandOwner=your-name. - Open ID Governance > Privileged Identity Management. Locate Microsoft Entra roles, Azure resources, Groups, My roles, and Approve requests. If unavailable, check licenses, tenant, active role, and portal access restrictions before proceeding.
Concepts that prevent the most common mistakes
Eligible means entitled to request activation, not already authorized. Active assigned is a direct administrator assignment. Activated is a temporary active assignment resulting from eligibility. Each can have its own eligibility/assignment lifetime; a 7-day eligibility does not grant 7 days of continuous active access.
Permanent eligible is not permanent active. A maximum activation duration is distinct from an assignment-expiration maximum. Directory scope / means the tenant; Azure scope is a resource ID. Graph and ARM use different identifiers and schemas.
References: LicensingPIM concepts and permissionsDeployment planning
Build the automation workbench
The snippets use the Microsoft Graph authentication module plus Invoke-MgGraphRequest so the HTTP payload remains visible. Azure exercises use Invoke-AzRestMethod. No legacy AzureAD/MSOnline modules, client secrets, or embedded credentials are used. Microsoft's retired /beta/privilegedAccess/aadRoles and /azureResources API surface was scheduled to stop returning data on 28 October 2026; these examples use the current Graph schedule and ARM resource APIs instead.
Install once, then download the shared bootstrap
Run locally in a trusted PowerShell session. If modules already exist, inspect their versions instead of blindly updating. Consent to delegated scopes is a separate prerequisite from the signed-in user's role. A consent administrator should pre-consent only the scopes required for the chosen exercise; do not give the operator PRA to work around an API error.
# Run only if these modules are missing in your lab workstation.
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery
Install-Module Az.Accounts -Scope CurrentUser -Repository PSGallery
Get-Module Microsoft.Graph.Authentication, Az.Accounts -ListAvailable |
Select-Object Name, Version
Save the following as 00-bootstrap.ps1 in a new local lab folder. In every new terminal run . .\00-bootstrap.ps1. Supply the same lab IDs each time. This defines helpers and initializes a read-only-by-default switch. Files and evidence are written under the current lab folder, not uploaded.
$ErrorActionPreference = 'Stop'
Import-Module Microsoft.Graph.Authentication
Import-Module Az.Accounts
$TenantId = Read-Host 'Disposable tenant ID'
$SubscriptionId = Read-Host 'Lab subscription ID'
$AdminId = Read-Host 'pim-admin object ID'
$OperatorId = Read-Host 'pim-operator object ID'
$ApproverId = Read-Host 'pim-approver object ID'
foreach ($id in @($TenantId,$SubscriptionId,$AdminId,$OperatorId,$ApproverId)) {
$parsed = [guid]::Empty
if (-not [guid]::TryParse($id, [ref]$parsed) -or $parsed -eq [guid]::Empty) {
throw 'Use actual non-empty GUIDs, not example names.'
}
}
if (@($AdminId,$OperatorId,$ApproverId | Select-Object -Unique).Count -ne 3) {
throw 'Admin, operator, and approver must be different users.'
}
$RgName = 'rg-pim-lab'
$AzureScope = "/subscriptions/$SubscriptionId/resourceGroups/$RgName"
$Graph = 'https://graph.microsoft.com/v1.0'
$GraphBeta = 'https://graph.microsoft.com/beta'
$GroupId = $null
$ApplyChanges = $false
$Evidence = Join-Path (Get-Location) 'pim-lab-evidence'
$null = New-Item -ItemType Directory -Path $Evidence -Force
function Connect-LabGraph {
param([string[]]$Scopes, [string]$ExpectedUserId)
if (Get-MgContext) { Disconnect-MgGraph | Out-Null }
Connect-MgGraph -TenantId $TenantId -Scopes (@('User.Read') + $Scopes) `
-ContextScope Process -NoWelcome
$me = Invoke-MgGraphRequest -Method GET -Uri "$Graph/me"
if ($me.id -ne $ExpectedUserId -or (Get-MgContext).TenantId -ne $TenantId) {
Disconnect-MgGraph | Out-Null
throw 'Wrong account or tenant. Sign in with the required lab actor.'
}
Write-Host "Graph actor: $($me.userPrincipalName) / $($me.id)"
}
function Get-LabGraphItems {
param([string]$Uri)
while ($Uri) {
$page = Invoke-MgGraphRequest -Method GET -Uri $Uri
foreach ($item in $page.value) { $item }
$Uri = $page.'@odata.nextLink'
}
}
function Write-LabGraph {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([string]$Uri, [string]$Method = 'POST', [object]$Body)
$json = if ($null -ne $Body) { $Body | ConvertTo-Json -Depth 40 }
if (-not $ApplyChanges) {
Write-Host "PREVIEW: $Method $Uri"
if ($json) { Write-Host $json }
return
}
if ($PSCmdlet.ShouldProcess($Uri, $Method)) {
$args = @{ Method=$Method; Uri=$Uri }
if ($json) { $args.Body=$json; $args.ContentType='application/json' }
Invoke-MgGraphRequest @args
}
}
function Connect-LabAzure {
param([string]$ExpectedUserId)
Disable-AzContextAutosave -Scope Process | Out-Null
Connect-AzAccount -Tenant $TenantId -Subscription $SubscriptionId `
-Scope Process | Out-Null
$ctx = Get-AzContext
if ($ctx.Tenant.Id -ne $TenantId -or $ctx.Subscription.Id -ne $SubscriptionId) {
throw 'Wrong Azure tenant or subscription.'
}
$token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token
if ($token -is [securestring]) {
$token = [System.Net.NetworkCredential]::new('', $token).Password
}
$part = $token.Split('.')[1].Replace('-', '+').Replace('_', '/')
$part = $part.PadRight($part.Length + ((4 - $part.Length % 4) % 4), '=')
$claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($part)) |
ConvertFrom-Json
if ($claims.oid -ne $ExpectedUserId -or $claims.tid -ne $TenantId) {
throw 'Wrong Azure actor. Reconnect as the required lab user.'
}
$token = $null
$claims = $null
Write-Host "ARM actor verified: $($ctx.Account.Id)"
}
function Read-LabArm {
param([string]$Path)
$r = Invoke-AzRestMethod -Method GET -Path $Path
if ($r.StatusCode -ge 400) { throw $r.Content }
$r.Content | ConvertFrom-Json -AsHashtable
}
function Get-LabArmItems {
param([string]$Path)
while ($Path) {
$page = Read-LabArm $Path
foreach ($item in $page.value) { $item }
$Path = $page.nextLink
if ($Path) { $Path = ([uri]$Path).PathAndQuery }
}
}
function Write-LabArm {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param([string]$Path, [string]$Method = 'PUT', [object]$Body)
$json = if ($null -ne $Body) { $Body | ConvertTo-Json -Depth 40 }
if (-not $ApplyChanges) {
Write-Host "PREVIEW: $Method $Path"
if ($json) { Write-Host $json }
return
}
if ($PSCmdlet.ShouldProcess($Path, $Method)) {
$args = @{ Method=$Method; Path=$Path }
if ($json) { $args.Payload=$json }
$r = Invoke-AzRestMethod @args
if ($r.StatusCode -ge 400) { throw $r.Content }
if ($r.Content) { $r.Content | ConvertFrom-Json -AsHashtable }
}
}
function Save-LabBaseline {
param([string]$Name, [object]$Value)
$path = Join-Path $Evidence $Name
if (-not (Test-Path -LiteralPath $path)) {
$Value | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $path -Encoding utf8
} else {
Write-Host "Keeping original baseline: $path"
}
}
Write-Host 'Read-only preview enabled. Set $ApplyChanges = $true only after inspecting a payload.'
$ApplyChanges = $true, rerun that block, and confirm the specific prompt. Return it to $false afterward. Never "Yes to all" a cleanup you have not inspected. A preview returns no request/object ID; downstream inspection only works after actual submission. -WhatIf is also supported by the wrappers.| Plane | API + authorization | Important distinction |
|---|---|---|
| Entra roles | Graph v1.0 /roleManagement/directory; delegated scope + user authority | Directory role definition ID, scope / or supported AU scope |
| PIM for Groups | Graph v1.0 /identityGovernance/privilegedAccess/group; group-specific scopes | groupId plus accessId=member/owner |
| Azure roles | ARM Microsoft.Authorization; Azure RBAC permissions at target scope | Fully qualified Azure role definition ID; AdminAssign / SelfActivate casing |
| Approval / alerts | Graph beta or ARM preview where marked | Not interchangeable with stable assignment APIs; use the portal if previews are disallowed |
. .\02-directory-inventory.ps1, so recorded IDs remain available in that actor's terminal. Alternatively paste each block directly. Do not execute every downloaded file in a batch: alternatives, separate identities, previews, and cleanup require deliberate selection. These are teaching scripts, not a rerunnable deployment engine.References: Graph eligibility APIARM eligibility APIGroups API overviewAPI migration and retirement
Discover and capture the baseline
- Open Assignments. Compare Eligible assignments, Active assignments, and Expired assignments. Filter for the operator and confirm there is no standing assignment that would invalidate the activation tests.
- Open Roles, find Reports Reader, and inspect its assignments and settings. This is the directory training role. Do not substitute Global Administrator. Even Reports Reader exposes tenant reporting; keep the tenant disposable.
- In PIM > Azure resources, choose the lab subscription and then the resource-group type/filter to locate
rg-pim-lab. Check its assignments. In Azure portal open the resource group's Access control (IAM) > Check access for the operator. Inspect inherited and group-based access too. - Record existing role settings before changes. Open Microsoft Entra roles > Discovery and insights (preview). Inspect Reduce Global Administrators, Eliminate standing access, and Review service principals without submitting changes. Its conversion/removal actions can change real assignments. Never select recovery administrators. For a safe conversion demonstration, use only a separate ordinary lab user's active Reports Reader comparison assignment: record the current end, create the equivalent eligible assignment, verify it, then remove the active assignment. These are separate operations, not an atomic transaction.
Automation / inventory and resolve the exact role
# Dot-source 00-bootstrap.ps1 first. Run as pim-admin.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleManagement.Read.Directory', 'RoleEligibilitySchedule.Read.Directory',
'RoleAssignmentSchedule.Read.Directory', 'RoleManagementPolicy.Read.Directory'
)
$filter = [uri]::EscapeDataString("displayName eq 'Reports Reader'")
$roles = @(Get-LabGraphItems "$Graph/roleManagement/directory/roleDefinitions?`$filter=$filter")
if ($roles.Count -ne 1) { throw 'Expected exactly one Reports Reader definition.' }
$DirectoryRoleId = $roles[0].id
$f = [uri]::EscapeDataString("principalId eq '$OperatorId'")
foreach ($kind in @('roleEligibilityScheduleInstances','roleAssignmentScheduleInstances')) {
$items = @(Get-LabGraphItems "$Graph/roleManagement/directory/$kind`?`$filter=$f")
$items | ConvertTo-Json -Depth 15 |
Set-Content (Join-Path $Evidence "$kind-before.json") -Encoding utf8
$items | Select-Object id, principalId, roleDefinitionId, directoryScopeId, endDateTime
}
Write-Host "Reports Reader roleDefinitionId: $DirectoryRoleId"
# Sign in as the lab subscription/resource administrator.
Connect-LabAzure -ExpectedUserId $AdminId
$rg = Read-LabArm "$AzureScope`?api-version=2021-04-01"
if ($rg.tags.Purpose -ne 'PIMLab') { throw 'Missing PIMLab resource group tag.' }
Get-LabArmItems "$AzureScope/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" |
Select-Object name, @{n='Principal';e={$_.properties.principalId}},
@{n='Scope';e={$_.properties.scope}}
# Portal IAM Check access is still required: direct assignments alone do not prove
# absence of inherited, nested-group, or other effective access paths.
References: PIM navigationDirectory assignmentsAzure assignmentsDiscovery and insights
Configure directory-role policies
- Capture the original Activation, Assignment, and Notification settings. Use the automation below to save a JSON baseline before making portal changes if you want scripted restoration.
- Under Activation, set maximum activation duration to 1 hour. Require MFA, justification, and ticket information. Set Require approval to activate; select
pim-approverexplicitly. A real deployment should designate at least two available approvers. - Under Assignment, require expiration for eligible and active assignments. Set maximum eligible duration to 7 days and maximum active assignment duration to 1 day. Require MFA and justification for administrators creating active assignments. If the UI uses larger units or restricts a value, use the nearest permitted value and keep the exercise assignment shorter.
- Under Notification, inspect all event/recipient rows, leave default notifications enabled, and add an authorized test mailbox to the administrator activation row. Enable all events for that row. Save with Update, reopen, and verify every tab.
- Explain the limits: ticket fields record data, but do not validate ServiceNow/Jira ticket state. MFA can be satisfied by an existing session claim; a prompt is not guaranteed for every activation. Approval is single-stage, not "everyone must approve."
Automation / map role to policy and preserve the original
# Requires $DirectoryRoleId from module 02.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleManagementPolicy.ReadWrite.Directory'
)
$f = [uri]::EscapeDataString("scopeId eq '/' and scopeType eq 'DirectoryRole'")
$assignments = @(Get-LabGraphItems "$Graph/policies/roleManagementPolicyAssignments?`$filter=$f")
$pa = @($assignments | Where-Object roleDefinitionId -eq $DirectoryRoleId)
if ($pa.Count -ne 1) { throw 'Expected one directory policy assignment.' }
$DirectoryPolicyId = $pa[0].policyId
$ruleBase = "$Graph/policies/roleManagementPolicies/$DirectoryPolicyId/rules"
$rules = @(Get-LabGraphItems $ruleBase)
Save-LabBaseline "directory-$DirectoryRoleId-baseline.json" @{
tenantId=$TenantId; policyId=$DirectoryPolicyId; rules=$rules
}
$changes = @(
@{id='Expiration_EndUser_Assignment'
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyExpirationRule'
isExpirationRequired=$true; maximumDuration='PT1H'},
@{id='Enablement_EndUser_Assignment'
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyEnablementRule'
enabledRules=@('MultiFactorAuthentication','Justification','Ticketing')},
@{id='Expiration_Admin_Eligibility'
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyExpirationRule'
isExpirationRequired=$true; maximumDuration='P7D'},
@{id='Expiration_Admin_Assignment'
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyExpirationRule'
isExpirationRequired=$true; maximumDuration='P1D'},
@{id='Enablement_Admin_Assignment'
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyEnablementRule'
enabledRules=@('MultiFactorAuthentication','Justification')}
)
foreach ($change in $changes) {
$existing = @($rules | Where-Object id -eq $change.id)
if ($existing.Count -ne 1) { throw "Missing rule: $($change.id)" }
$change.target = $existing[0].target
Write-LabGraph -Method PATCH -Uri "$ruleBase/$($change.id)" -Body $change
}
$approval = @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyApprovalRule'
id='Approval_EndUser_Assignment'
target=($rules | Where-Object id -eq 'Approval_EndUser_Assignment').target
setting=@{
isApprovalRequired=$true; isApprovalRequiredForExtension=$false
isRequestorJustificationRequired=$true; approvalMode='SingleStage'
approvalStages=@(@{
approvalStageTimeOutInDays=1; isApproverJustificationRequired=$true
escalationTimeInMinutes=0; isEscalationEnabled=$false
primaryApprovers=@(@{
'@odata.type'='#microsoft.graph.singleUser'; userId=$ApproverId
})
escalationApprovers=@()
})
}
}
Write-LabGraph -Method PATCH -Uri "$ruleBase/Approval_EndUser_Assignment" -Body $approval
$mail = Read-Host 'Authorized lab notification mailbox (email address)'
$notificationId = 'Notification_Admin_EndUser_Assignment'
$notification = @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyNotificationRule'
id=$notificationId
target=($rules | Where-Object id -eq $notificationId).target
notificationType='Email'; recipientType='Admin'; notificationLevel='All'
isDefaultRecipientsEnabled=$true; notificationRecipients=@($mail)
}
Write-LabGraph -Method PATCH -Uri "$ruleBase/$notificationId" -Body $notification
Get-LabGraphItems $ruleBase | ConvertTo-Json -Depth 30 |
Set-Content (Join-Path $Evidence 'directory-policy-after.json') -Encoding utf8
The script patches individual rules and retains each discovered target. It does not replace unrelated rules. Arrays such as enabledRules and approvers are intentionally replaced for this disposable role policy; review them before applying.
References: Portal policy settingsDiscover policy assignmentsPatch a ruleRule examplesNotification schemaEnablement schema
Assign eligible and active directory roles
- Select Reports Reader, choose
pim-operator, and select Next. Set type Eligible, deselect permanent eligibility, and choose now through 7 days from now. Assign. Record actual UTC start/end values. - Open Eligible assignments and verify the operator, role, scope, and expiration. In the operator profile, check My roles > Microsoft Entra roles > Eligible assignments. Do not activate yet.
- Optional comparison: use a separate ordinary lab user for a 1-hour active assignment. On Add assignments choose Active, a one-hour window, and a reason. The user has access without self-activation. Remove that comparison assignment after at least five minutes. Do not use the approver or recovery account for this comparison.
- Inspect the permanent toggles without saving: policy can prohibit permanent eligible or permanent active access. Do not weaken the policy just to create a permanent assignment. Record which settings would have to change and why permanent eligible still requires activation.
Automation / seven-day eligibility
# Requires $DirectoryRoleId from module 02.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleEligibilitySchedule.ReadWrite.Directory'
)
$body = @{
action='adminAssign'; principalId=$OperatorId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification='PIM-LAB: seven-day Reports Reader training eligibility'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDateTime'; endDateTime=[datetime]::UtcNow.AddDays(7).ToString('o')}
}
}
$eligibilityRequest = Write-LabGraph `
-Uri "$Graph/roleManagement/directory/roleEligibilityScheduleRequests" -Body $body
if ($eligibilityRequest) {
$eligibilityRequest | ConvertTo-Json -Depth 15 |
Set-Content (Join-Path $Evidence 'directory-eligibility-request.json') -Encoding utf8
$eligibilityRequest | Select-Object id, status, targetScheduleId
}
Automation / optional active-assigned comparison
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleAssignmentSchedule.ReadWrite.Directory'
)
$ComparisonId = Read-Host 'Object ID of a separate ordinary disposable comparison user'
if ($ComparisonId -in @($AdminId,$OperatorId,$ApproverId)) {
throw 'Use a separate comparison account to avoid masking the main tests.'
}
$body = @{
action='adminAssign'; principalId=$ComparisonId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification='PIM-LAB: compare active assigned with eligible'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDuration'; duration='PT1H'}
}
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $body
# After at least 5 minutes, remove this assignment using the same body:
# $body.action = 'adminRemove'
# $body.Remove('scheduleInfo')
# Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $body
References: Assign in the portalEligible requestsActive requests
Request, approve, deny, cancel and deactivate
- Before activation, open Entra ID > Monitoring & health > Sign-in logs and try to view another lab user's events, such as the admin's recent sign-in. Record the denied/unavailable state. Ordinary users can view their own sign-ins, so your own history is not a valid privilege test. Have the admin sign in recently to ensure a positive test event exists.
- Select Activate. Complete additional verification when required. Use duration 30 minutes, reason
PIM-LAB: inspect test reporting, ticket systemLAB, ticket numberCHG-PIM-001. Submit and record the request ID. Status should become pending approval rather than immediately granting access. - In the approver profile, open PIM > Approve requests > Microsoft Entra roles. Inspect the requester, scope, reason, duration, and ticket. Select the request, choose Approve, provide
Approved for isolated lab, and submit. - Return as operator. Refresh My roles > Active assignments. Once provisioned, refresh the report or sign out/in to obtain a fresh application session. Record successful access and activation expiry. Check the relevant notification mailbox; approval, activation, and admin notifications are different events.
- Under Active assignments choose Deactivate once the minimum assignment interval has elapsed. Confirm the role is no longer active. Re-test after refreshing credentials; downstream applications may cache authorization.
- Submit a second activation. The approver chooses Deny with a reason. Confirm the denied request did not create an active assignment.
- Submit a third activation, then use My requests to Cancel while it is still pending. Cancellation is not deactivation. Finally, test requesting two hours: policy should prevent/reject an activation longer than one hour. Capture the error; do not change the cap.
Automation / operator request, inspect, deactivate or cancel
In the operator terminal, dot-source the bootstrap and set $DirectoryRoleId to the ID recorded in module 02. An eligible user may perform self-actions without PRA. The delegated client still needs consent for the API scope, and the signed-in session must satisfy authentication policy.
Connect-LabGraph -ExpectedUserId $OperatorId -Scopes @(
'RoleAssignmentSchedule.ReadWrite.Directory'
)
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId from module 02'
$activationBody = @{
action='selfActivate'; principalId=$OperatorId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification='PIM-LAB: inspect test reporting'
ticketInfo=@{ticketSystem='LAB'; ticketNumber='CHG-PIM-001'}
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDuration'; duration='PT30M'}
}
}
$activation = Write-LabGraph `
-Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $activationBody
if ($activation) {
$activation | ConvertTo-Json -Depth 15 |
Set-Content (Join-Path $Evidence 'directory-activation-request.json') -Encoding utf8
$DirectoryRequestId = $activation.id
$activation | Select-Object id, status, approvalId, targetScheduleId
}
# Once submitted, inspect the actual request (do not blindly resubmit on timeouts).
if ($DirectoryRequestId) {
Invoke-MgGraphRequest -Method GET `
-Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests/$DirectoryRequestId"
}
# Same operator connection. Choose ONE action at the prompt.
$choice = Read-Host 'Type deactivate for an active assignment, or cancel for a pending request'
if ($choice -eq 'deactivate') {
$body = @{
action='selfDeactivate'; principalId=$OperatorId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification='PIM-LAB: task finished'
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $body
} elseif ($choice -eq 'cancel') {
$DirectoryRequestId = Read-Host 'Exact pending request ID to cancel'
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests/$DirectoryRequestId/cancel"
} else { throw 'No action selected.' }
Automation / delegated approver (Graph beta)
Use a separate approver terminal. A service principal cannot approve this flow. The beta endpoint is deliberately visible. Prefer the portal if beta APIs are not allowed in your environment; extension/renewal approval is not supported by this activation-approval API.
Connect-LabGraph -ExpectedUserId $ApproverId -Scopes @(
'RoleAssignmentSchedule.ReadWrite.Directory'
)
$DirectoryRequestId = Read-Host 'Exact operator activation request ID'
$req = Invoke-MgGraphRequest -Method GET `
-Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests/$DirectoryRequestId"
if ($req.principalId -ne $OperatorId -or $req.status -ne 'PendingApproval') {
throw 'Not the expected pending operator request.'
}
$approvalId = $req.approvalId
if (-not $approvalId) { throw 'No approval ID; inspect the request in the portal.' }
$approval = Invoke-MgGraphRequest -Method GET `
-Uri "$GraphBeta/roleManagement/directory/roleAssignmentApprovals/$approvalId"
$steps = @($approval.steps | Where-Object { $_.assignedToMe -and $_.status -eq 'InProgress' })
if ($steps.Count -ne 1) { throw 'Expected one pending step assigned to this approver.' }
$decision = Read-Host 'Approve or Deny'
if ($decision -notin @('Approve','Deny')) { throw 'Invalid decision.' }
Write-LabGraph -Method PATCH `
-Uri "$GraphBeta/roleManagement/directory/roleAssignmentApprovals/$approvalId/steps/$($steps[0].id)" `
-Body @{reviewResult=$decision; justification='PIM-LAB: reviewed isolated training request'}
Automation / verify a protected read before, during, and after activation
# Run this same read in each phase; record whether access is authorized.
# Pre-consent the delegated scope using an authorized consent administrator.
Connect-LabGraph -ExpectedUserId $OperatorId -Scopes @('AuditLog.Read.All')
$since = [datetime]::UtcNow.AddHours(-2).ToString('yyyy-MM-ddTHH:mm:ssZ')
$f = [uri]::EscapeDataString("userId eq '$AdminId' and createdDateTime ge $since")
$response = Invoke-MgGraphRequest -Method GET `
-Uri "$Graph/auditLogs/signIns?`$filter=$f&`$top=1"
$response.value | Select-Object id, userId, createdDateTime, appDisplayName
# A 200 with no rows is not proof of privilege: ensure a matching admin event
# exists, and verify an event for the OTHER user is actually returned.
# Reconnect afterward with RoleAssignmentSchedule.ReadWrite.Directory for self-actions.
References: Activation and cache behaviorApproval workflowSelf-actionsBeta approval permissionsSign-in read authorization
Expire, extend, renew and revoke
Extend means change the end of an unexpired assignment; renew means restore a recently expired assignment. Neither should be confused with leaving one activation running indefinitely.
- As operator open PIM > My roles > Microsoft Entra roles > Eligible assignments. For the seven-day eligibility choose Extend, when offered. Enter a business reason. The self-extension action is available when the assignment is within its extension window (documented as the next 14 days).
- As PRA open PIM > Microsoft Entra roles > Approve requests or the corresponding extension/renewal request blade. Inspect the proposed dates and approve only a period allowed by the role's assignment policy. Confirm the eligibility end date changed. An extension request does not automatically extend access.
- For a short expiry exercise, create an additional eligibility for a separate disposable user with a 15-minute end date. Do not overwrite the main operator's seven-day assignment. Wait until it expires, refresh Expired assignments, then use Renew and verify the new end date. If an expired record is no longer available, create a new assignment rather than treating an old ID as valid forever.
- As PRA use the assignment's Update action to adjust an unexpired end date within policy. To test revocation, use Remove only on the short-lived comparison assignment. Removing eligibility and removing an active session are separate checks; explicitly inspect active assignments too.
- As operator activate for 15 minutes without manually deactivating. Record UTC expiry, wait, and verify PIM removes the active assignment. Re-check effective access after a fresh session. Do not assume removal instantly invalidates every cached application token.
Automation / administrator lifecycle actions
Use this block once per selected action. For adminRenew, use the comparison user's expired eligibility. The portal remains the preferred teaching path for approving user-initiated extension/renewal requests.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleEligibilitySchedule.ReadWrite.Directory'
)
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId'
$LifecyclePrincipalId = Read-Host 'Exact lab principal object ID whose eligibility is being changed'
$action = Read-Host 'adminUpdate, adminExtend, adminRenew, or adminRemove'
if ($action -notin @('adminUpdate','adminExtend','adminRenew','adminRemove')) {
throw 'Unsupported lab action.'
}
if ($LifecyclePrincipalId -in @($AdminId,$ApproverId)) {
throw 'Do not change admin or approver assignments in this exercise.'
}
$body = @{
action=$action; principalId=$LifecyclePrincipalId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification="PIM-LAB: explicit lifecycle exercise $action"
}
if ($action -ne 'adminRemove') {
$end = [datetimeoffset]::Parse((Read-Host 'New UTC eligibility end, within role policy (ISO 8601)'))
if ($end -le [datetimeoffset]::UtcNow.AddMinutes(5)) { throw 'End must be in the future.' }
# For Extend, choose an end LATER than the existing end; for Renew, use
# the recently expired comparison assignment. Inspect policy before applying.
$body.scheduleInfo = @{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDateTime'; endDateTime=$end.UtcDateTime.ToString('o')}
}
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleEligibilityScheduleRequests" -Body $body
# For an ACTIVE assigned role, use roleAssignmentScheduleRequests instead.
# Do not DELETE a historical request object to revoke a schedule.
Connect-LabGraph -ExpectedUserId $OperatorId -Scopes @(
'RoleEligibilitySchedule.ReadWrite.Directory'
)
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId'
$action = Read-Host 'selfExtend for unexpired eligibility, or selfRenew for expired eligibility'
if ($action -notin @('selfExtend','selfRenew')) { throw 'Invalid self action.' }
$end = [datetimeoffset]::Parse((Read-Host 'Requested new UTC end (later than existing end for Extend), within policy'))
if ($end -le [datetimeoffset]::UtcNow.AddMinutes(5)) { throw 'Requested end must be in the future.' }
$body = @{
action=$action; principalId=$OperatorId
roleDefinitionId=$DirectoryRoleId; directoryScopeId='/'
justification='PIM-LAB: request continued lab eligibility'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDateTime'; endDateTime=$end.UtcDateTime.ToString('o')}
}
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleEligibilityScheduleRequests" -Body $body
References: Extension/renewal behaviorEligibility lifecycle actionsActive lifecycle actions
Configure Azure scope and role settings
- Locate the lab resource group through Azure resources. If it does not appear, use the resource-type filter and refresh discovery. Verify the signed-in user has the required Azure permissions at this scope. Being PRA in Entra is not sufficient.
- Open Settings > Contributor. Capture the original settings. Configure one-hour activation, MFA, justification, ticketing, and explicit approval by
pim-approver. - On assignment settings require expiration for eligible and active assignments. Use maximum eligible seven days and active one day where accepted. Inspect notifications and add only an authorized test address. Save and reopen.
- In Azure portal open rg-pim-lab > Access control (IAM). Compare Role assignments with the PIM assignment views. Role policy configuration is at a specific role/resource scope; do not assume configuring the subscription also configures every resource group's policy.
- Explain why this role is selected: Contributor can change resources but cannot generally assign Azure roles. An empty resource group plus tag changes gives a small, testable authorization boundary without deploying paid workloads.
Automation / create the tagged resource group if you skipped portal creation
Connect-LabAzure -ExpectedUserId $AdminId
$existing = Invoke-AzRestMethod -Method GET -Path "$AzureScope`?api-version=2021-04-01"
if ($existing.StatusCode -eq 200) {
$found = $existing.Content | ConvertFrom-Json
if ($found.tags.Purpose -ne 'PIMLab') { throw 'Existing non-lab resource group. Stop.' }
Write-Host 'Tagged lab resource group already exists; not overwriting it.'
} elseif ($existing.StatusCode -eq 404) {
$region = Read-Host 'Approved Azure region, for example northeurope'
Write-LabArm -Path "$AzureScope`?api-version=2021-04-01" -Body @{
location=$region; tags=@{Purpose='PIMLab'; Owner='lab-operator'}
}
} else { throw $existing.Content }
Automation / discover and patch the ARM policy
Connect-LabAzure -ExpectedUserId $AdminId
$AzureRoleGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c' # Contributor
$AzureRoleDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$AzureRoleGuid"
$pas = @(Get-LabArmItems "$AzureScope/providers/Microsoft.Authorization/roleManagementPolicyAssignments?api-version=2020-10-01")
$pa = @($pas | Where-Object {
$_.properties.roleDefinitionId.Split('/')[-1] -eq $AzureRoleGuid
})
if ($pa.Count -ne 1) { throw 'Expected one Contributor policy at the exact RG scope.' }
$AzurePolicyPath = $pa[0].properties.policyId
$policy = Read-LabArm "$AzurePolicyPath`?api-version=2020-10-01"
Save-LabBaseline 'azure-contributor-baseline.json' @{
tenantId=$TenantId; scope=$AzureScope; policyPath=$AzurePolicyPath
rules=$policy.properties.rules
}
$rules = $policy.properties.rules
$changes = @()
foreach ($spec in @(
@{id='Expiration_EndUser_Assignment'; duration='PT1H'},
@{id='Expiration_Admin_Eligibility'; duration='P7D'},
@{id='Expiration_Admin_Assignment'; duration='P1D'}
)) {
$old = @($rules | Where-Object id -eq $spec.id)
if ($old.Count -ne 1) { throw "Missing ARM rule $($spec.id)" }
$changes += @{
id=$spec.id; ruleType='RoleManagementPolicyExpirationRule'
isExpirationRequired=$true; maximumDuration=$spec.duration; target=$old[0].target
}
}
$changes += @{
id='Enablement_EndUser_Assignment'; ruleType='RoleManagementPolicyEnablementRule'
target=($rules | Where-Object id -eq 'Enablement_EndUser_Assignment').target
enabledRules=@('MultiFactorAuthentication','Justification','Ticketing')
}
$changes += @{
id='Approval_EndUser_Assignment'; ruleType='RoleManagementPolicyApprovalRule'
target=($rules | Where-Object id -eq 'Approval_EndUser_Assignment').target
setting=@{
isApprovalRequired=$true; isApprovalRequiredForExtension=$false
isRequestorJustificationRequired=$true; approvalMode='SingleStage'
approvalStages=@(@{
approvalStageTimeOutInDays=1; isApproverJustificationRequired=$true
escalationTimeInMinutes=0; isEscalationEnabled=$false
primaryApprovers=@(@{id=$ApproverId; userType='User'; isBackup=$false})
escalationApprovers=@()
})
}
}
$mail = Read-Host 'Authorized lab notification mailbox'
$notificationId = 'Notification_Admin_EndUser_Assignment'
$changes += @{
id=$notificationId; ruleType='RoleManagementPolicyNotificationRule'
target=($rules | Where-Object id -eq $notificationId).target
notificationType='Email'; recipientType='Admin'; notificationLevel='All'
isDefaultRecipientsEnabled=$true; notificationRecipients=@($mail)
}
Write-LabArm -Method PATCH -Path "$AzurePolicyPath`?api-version=2020-10-01" `
-Body @{properties=@{rules=$changes}}
Read-LabArm "$AzurePolicyPath`?api-version=2020-10-01" |
ConvertTo-Json -Depth 40 |
Set-Content (Join-Path $Evidence 'azure-policy-after.json') -Encoding utf8
@odata.type rules versus ARM ruleType rules and their different approver schemas. The policy path and scope in the saved baseline point only at the lab resource group.References: Azure policy portal settingsARM policy update and schemaAzure authority and assignment scope
Azure eligibility, activation and approval
- As resource admin, open PIM > Azure resources > rg-pim-lab > Roles > Add assignments. Select Contributor and the operator. Choose Eligible, start now, end in seven days. Assign.
- As operator, first attempt to edit a harmless resource-group tag. It should fail or be unavailable. Open PIM > My roles > Azure resources > Eligible assignments, locate the exact RG-scoped Contributor assignment, and select Activate.
- Use 30 minutes, reason
PIM-LAB: tag resource group, and ticketCHG-PIM-002. Do not activate a similarly named subscription-wide role. If offered a reduced activation scope, inspect it; use the RG scope for this exercise. - As approver, open Approve requests > Azure resources, inspect the exact subscription/RG, approve with justification, and return as operator. After provisioning and session refresh, set
PIMLabActivation=verifiedon the resource group. Do not change any other resource. - Deactivate the role after the minimum interval. Retry the tag edit with refreshed credentials and record the denial. Repeat one denial and one pending cancellation if you want equivalent evidence to module 05.
- Inspect My roles for Extend or Renew on eligible/active-assigned Azure schedules. These change assignment lifetimes, not the role's activation cap. Resolve extension/renewal approvals in the portal rather than through the activation approval API.
Automation / resource administrator creates eligibility
Connect-LabAzure -ExpectedUserId $AdminId
$AzureRoleGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
$AzureRoleDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$AzureRoleGuid"
$requestName = [guid]::NewGuid().ToString()
$body = @{properties=@{
principalId=$OperatorId; roleDefinitionId=$AzureRoleDefinitionId
requestType='AdminAssign'; justification='PIM-LAB: seven-day RG Contributor eligibility'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='AfterDateTime'; endDateTime=[datetime]::UtcNow.AddDays(7).ToString('o')}
}
}}
$r = Write-LabArm -Path "$AzureScope/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/$requestName`?api-version=2020-10-01" -Body $body
if ($r) {
$r | ConvertTo-Json -Depth 20 |
Set-Content (Join-Path $Evidence 'azure-eligibility-request.json') -Encoding utf8
$r.properties | Select-Object status, targetRoleEligibilityScheduleId
}
Automation / operator activates and proves authorization
Connect-LabAzure -ExpectedUserId $OperatorId
$AzureRoleGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
$AzureRoleDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$AzureRoleGuid"
$f = [uri]::EscapeDataString("principalId eq '$OperatorId'")
$eligible = @(Get-LabArmItems "$AzureScope/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=$f" |
Where-Object {
$_.properties.roleDefinitionId.Split('/')[-1] -eq $AzureRoleGuid -and
$_.properties.scope -eq $AzureScope
})
if ($eligible.Count -ne 1) { throw 'Expected one exact-scope Contributor eligibility instance.' }
$AzureActivationName = [guid]::NewGuid().ToString()
$body = @{properties=@{
principalId=$OperatorId; roleDefinitionId=$AzureRoleDefinitionId
linkedRoleEligibilityScheduleId=$eligible[0].properties.roleEligibilityScheduleId
requestType='SelfActivate'; justification='PIM-LAB: tag resource group'
ticketInfo=@{ticketSystem='LAB'; ticketNumber='CHG-PIM-002'}
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='AfterDuration'; duration='PT30M'}
}
}}
$r = Write-LabArm -Path "$AzureScope/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$AzureActivationName`?api-version=2020-10-01" -Body $body
if ($r) {
$r | ConvertTo-Json -Depth 20 |
Set-Content (Join-Path $Evidence 'azure-activation-request.json') -Encoding utf8
$r.properties | Select-Object status, approvalId, targetRoleAssignmentScheduleId
Write-Host "Activation request name: $AzureActivationName"
}
# Only AFTER approval and provisioning. Run as operator.
Connect-LabAzure -ExpectedUserId $OperatorId
Write-LabArm -Method PATCH `
-Path "$AzureScope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01" `
-Body @{operation='Merge'; properties=@{tags=@{PIMLabActivation='verified'}}}
# After at least five minutes, deactivate the exact active assignment.
$f = [uri]::EscapeDataString("principalId eq '$OperatorId'")
$active = @(Get-LabArmItems "$AzureScope/providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01&`$filter=$f" |
Where-Object {
$_.properties.roleDefinitionId.Split('/')[-1] -eq 'b24988ac-6180-42a0-ab88-20f7382dd24c' -and
$_.properties.scope -eq $AzureScope -and $_.properties.assignmentType -eq 'Activated'
})
if ($active.Count -ne 1) { throw 'Expected one lab activation, not a standing assignment.' }
$body = @{properties=@{
principalId=$OperatorId; roleDefinitionId=$active[0].properties.roleDefinitionId
requestType='SelfDeactivate'; justification='PIM-LAB: task finished'
targetRoleAssignmentScheduleId=$active[0].properties.roleAssignmentScheduleId
}}
$name = [guid]::NewGuid().ToString()
Write-LabArm -Path "$AzureScope/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$name`?api-version=2020-10-01" -Body $body
# A pending request instead uses POST .../roleAssignmentScheduleRequests/{name}/cancel
# with api-version=2020-10-01; it does not use SelfDeactivate.
Automation / Azure activation approval (ARM preview)
Read the activation request's properties.approvalId. The approval stage API is preview and uses a provider-root path, not a Graph path. Use only a stage assigned to the signed-in approver.
Connect-LabAzure -ExpectedUserId $ApproverId
$ApprovalId = Read-Host 'Approval GUID from the pending ARM activation request'
$base = "/providers/Microsoft.Authorization/roleAssignmentApprovals/$ApprovalId"
$approval = Read-LabArm "$base`?api-version=2021-01-01-preview"
$stages = @($approval.properties.stages)
$mine = @($stages | Where-Object {
$_.properties.assignedToMe -and $_.properties.status -eq 'InProgress'
})
if ($mine.Count -ne 1) { throw 'No unique in-progress stage assigned to this approver.' }
$decision = Read-Host 'Approve or Deny'
if ($decision -notin @('Approve','Deny')) { throw 'Invalid decision.' }
$stagePath = $mine[0].id
Write-LabArm -Method PATCH -Path "$stagePath`?api-version=2021-01-01-preview" `
-Body @{reviewResult=$decision; justification='PIM-LAB: reviewed RG activation'}
References: Azure activationEligibility payloadActivation payloadApproval preview and limitations
Onboard PIM for Groups and set policies
- Open Entra ID > Groups > All groups > New group. Choose Security, name
PIM-Lab-Readers, descriptionDisposable PIM training group, and Assigned membership. For the directory-role bundle in module 10, set Microsoft Entra roles can be assigned to the group = Yes at creation time. This cannot simply be switched on later. - Set
pim-adminas an active owner. Do not make the operator a direct member. Create the group and record its object ID. A normal security group is sufficient for Azure/SaaS grouping; role-assignability is required only when assigning Entra directory roles to the group. - Open PIM > Groups > Discover groups, select the new group, choose Manage groups > OK. Return to Groups and open it.
- Under Settings configure Member: maximum activation 1 hour, MFA, justification, approval by
pim-approver, expiring eligible/active assignments, and notifications to the lab mailbox. Set 7-day eligible and 1-day active maxima if supported by the UI. - Configure Owner separately, with the same controls. Never assume Member policy applies to Owner. Preserve the active administrator owner; eligible ownership is not a reliable recovery mechanism.
- Under Assignments > Add assignments, choose Member, operator, Eligible, seven days. For a separate ownership demonstration you can later add an Owner eligibility to the operator, but do not activate Member and Owner simultaneously during baseline tests.
Automation / create the group, or adopt its recorded ID
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'Group.ReadWrite.All','RoleManagement.ReadWrite.Directory'
)
$f = [uri]::EscapeDataString("displayName eq 'PIM-Lab-Readers'")
$existing = @(Get-LabGraphItems "$Graph/groups?`$filter=$f")
if ($existing.Count) { throw 'Group name already exists. Adopt its verified ID; do not create a duplicate.' }
$body = @{
displayName='PIM-Lab-Readers'; description='Disposable PIM training group'
mailEnabled=$false; mailNickname="PIMLab$([guid]::NewGuid().ToString('N').Substring(0,8))"
securityEnabled=$true; isAssignableToRole=$true; groupTypes=@()
'owners@odata.bind'=@("$Graph/users/$AdminId")
}
$created = Write-LabGraph -Uri "$Graph/groups" -Body $body
if ($created) {
$GroupId = $created.id
Write-Host "Record group ID: $GroupId"
$created | ConvertTo-Json -Depth 15 |
Set-Content (Join-Path $Evidence 'lab-group.json') -Encoding utf8
}
Automation / eligibility automatically enrolls the group
There is no need to invent an "enable PIM" Graph endpoint. Creating a supported schedule request automatically onboards an unmanaged group. After onboarding, rediscover policy IDs because they can change. If the portal already created the membership eligibility, skip the write and just inspect policies.
$GroupId = Read-Host 'Verified PIM-Lab-Readers object ID'
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup',
'RoleManagementPolicy.ReadWrite.AzureADGroup'
)
$body = @{
action='adminAssign'; accessId='member'; principalId=$OperatorId; groupId=$GroupId
justification='PIM-LAB: seven-day eligible membership'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDateTime'; endDateTime=[datetime]::UtcNow.AddDays(7).ToString('o')}
}
}
Write-LabGraph -Uri "$Graph/identityGovernance/privilegedAccess/group/eligibilityScheduleRequests" -Body $body
# Finish onboarding before running the policy-discovery block below.
# Run only after group onboarding. Same admin connection as above.
$f = [uri]::EscapeDataString("scopeId eq '$GroupId' and scopeType eq 'Group'")
$pas = @(Get-LabGraphItems "$Graph/policies/roleManagementPolicyAssignments?`$filter=$f")
foreach ($access in @('member','owner')) {
$pa = @($pas | Where-Object roleDefinitionId -eq $access)
if ($pa.Count -ne 1) { throw "Expected one $access policy." }
$groupPolicyId = $pa[0].policyId
$base = "$Graph/policies/roleManagementPolicies/$groupPolicyId/rules"
$rules = @(Get-LabGraphItems $base)
Save-LabBaseline "group-$GroupId-$access-baseline.json" @{
tenantId=$TenantId; groupId=$GroupId; policyId=$groupPolicyId; rules=$rules
}
foreach ($spec in @(
@{id='Expiration_EndUser_Assignment'; duration='PT1H'},
@{id='Expiration_Admin_Eligibility'; duration='P7D'},
@{id='Expiration_Admin_Assignment'; duration='P1D'}
)) {
Write-LabGraph -Method PATCH -Uri "$base/$($spec.id)" -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyExpirationRule'
id=$spec.id; target=($rules | Where-Object id -eq $spec.id).target
isExpirationRequired=$true; maximumDuration=$spec.duration
}
}
Write-LabGraph -Method PATCH -Uri "$base/Enablement_EndUser_Assignment" -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyEnablementRule'
id='Enablement_EndUser_Assignment'
target=($rules | Where-Object id -eq 'Enablement_EndUser_Assignment').target
enabledRules=@('MultiFactorAuthentication','Justification')
}
Write-LabGraph -Method PATCH -Uri "$base/Approval_EndUser_Assignment" -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyApprovalRule'
id='Approval_EndUser_Assignment'
target=($rules | Where-Object id -eq 'Approval_EndUser_Assignment').target
setting=@{
isApprovalRequired=$true; isApprovalRequiredForExtension=$false
isRequestorJustificationRequired=$true; approvalMode='SingleStage'
approvalStages=@(@{
approvalStageTimeOutInDays=1; isApproverJustificationRequired=$true
escalationTimeInMinutes=0; isEscalationEnabled=$false
primaryApprovers=@(@{
'@odata.type'='#microsoft.graph.singleUser'; userId=$ApproverId
})
escalationApprovers=@()
})
}
}
$mail = Read-Host "Notification mailbox for $access activations"
$nid = 'Notification_Admin_EndUser_Assignment'
Write-LabGraph -Method PATCH -Uri "$base/$nid" -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyNotificationRule'
id=$nid; target=($rules | Where-Object id -eq $nid).target
notificationType='Email'; recipientType='Admin'; notificationLevel='All'
isDefaultRecipientsEnabled=$true; notificationRecipients=@($mail)
}
}
References: Portal onboarding and restrictionsMember/Owner policiesAssign eligible accessAutomatic onboardingGroup eligibility API
Activate membership, ownership and role bundles
- As operator open PIM > My roles > Groups > Eligible assignments. Select the lab group's Member eligibility and activate for 30 minutes with justification.
- As approver open Approve requests > Groups, inspect the requested relationship and group, and approve. Return as admin to Entra ID > Groups > PIM-Lab-Readers > Members. The operator should now be an active member.
- Deactivate membership after five minutes. Verify the operator disappears from active Members but remains in PIM eligible assignments. Test one denial as in module 05.
- Optional ownership exercise: as admin add a seven-day Owner eligibility for the operator. Activate it through the separate owner policy, confirm the operator appears under Owners, then deactivate. Being an owner does not itself make the user a member or grant directory roles assigned to members.
- Role-bundle exercise: first ensure the operator's direct Reports Reader activation is inactive. As PRA, open Entra ID > Roles & admins > Reports Reader > Add assignments, select the role-assignable lab group, and make the group active assigned for no more than one day. The policy in module 03 requires expiration. Use PIM's active-assignment flow if the standard blade does not offer a duration.
- Reactivate group membership. Verify role-derived reporting access via the group. You are activating membership, not individually activating Reports Reader. The directory role's self-activation approval does not substitute for the group's membership approval. Avoid making both group membership and the group's role assignment eligible, which creates confusing nested activation dependencies.
- Deactivate membership and remove the group's active directory-role assignment when the test ends. Check for direct membership edits by group owners: those are separate access paths that can bypass the intended just-in-time membership workflow and must be monitored.
Automation / Member or Owner activation
$GroupId = Read-Host 'Verified lab group object ID'
Connect-LabGraph -ExpectedUserId $OperatorId -Scopes @(
'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup'
)
$access = Read-Host 'member or owner (must have matching eligibility)'
if ($access -notin @('member','owner')) { throw 'Invalid relationship.' }
$body = @{
action='selfActivate'; accessId=$access; principalId=$OperatorId; groupId=$GroupId
justification="PIM-LAB: test $access activation"
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDuration'; duration='PT30M'}
}
}
$groupRequest = Write-LabGraph `
-Uri "$Graph/identityGovernance/privilegedAccess/group/assignmentScheduleRequests" -Body $body
if ($groupRequest) {
$groupRequest | ConvertTo-Json -Depth 20 |
Set-Content (Join-Path $Evidence "group-$access-activation.json") -Encoding utf8
$groupRequest | Select-Object id, status, approvalId, targetScheduleId
}
# After approval, provisioning, and at least five minutes, deactivate:
# $body.action = 'selfDeactivate'
# $body.Remove('scheduleInfo')
# Write-LabGraph -Uri "$Graph/identityGovernance/privilegedAccess/group/assignmentScheduleRequests" -Body $body
# Run as admin; group must be role-assignable.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleAssignmentSchedule.ReadWrite.Directory'
)
$GroupId = Read-Host 'Verified role-assignable lab group object ID'
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId'
$body = @{
action='adminAssign'; principalId=$GroupId; roleDefinitionId=$DirectoryRoleId
directoryScopeId='/'; justification='PIM-LAB: one-day group role bundle'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDuration'; duration='P1D'}
}
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $body
# Remove the group-role binding after the exercise (wait at least five minutes):
# $body.action = 'adminRemove'
# $body.Remove('scheduleInfo')
# Write-LabGraph -Uri "$Graph/roleManagement/directory/roleAssignmentScheduleRequests" -Body $body
Automation / group approval (Graph beta)
Connect-LabGraph -ExpectedUserId $ApproverId -Scopes @(
'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup'
)
$GroupRequestId = Read-Host 'Exact group activation request ID'
$req = Invoke-MgGraphRequest -Method GET `
-Uri "$Graph/identityGovernance/privilegedAccess/group/assignmentScheduleRequests/$GroupRequestId"
if ($req.principalId -ne $OperatorId -or $req.status -ne 'PendingApproval') {
throw 'Unexpected request; inspect in portal.'
}
$aid = $req.approvalId
if (-not $aid) { throw 'No approval ID on this request.' }
$base = "$GraphBeta/identityGovernance/privilegedAccess/group/assignmentApprovals/$aid"
$approval = Invoke-MgGraphRequest -Method GET -Uri $base
$mine = @($approval.steps | Where-Object { $_.assignedToMe -and $_.status -eq 'InProgress' })
if ($mine.Count -ne 1) { throw 'Expected one pending step for this approver.' }
$decision = Read-Host 'Approve or Deny'
if ($decision -notin @('Approve','Deny')) { throw 'Invalid decision.' }
Write-LabGraph -Method PATCH -Uri "$base/steps/$($mine[0].id)" `
-Body @{reviewResult=$decision; justification='PIM-LAB: reviewed group request'}
References: Activate group accessGroup approvalsAssignment requestsBeta approval resourceRole-assignable groups
Conditional Access authentication context
- As Conditional Access Administrator, open Entra ID > Conditional Access > Authentication context. Create/publish
PIM-Lab-StrongAuth, note itscNID, and do not reuse an existing application's context. - Under Conditional Access > Policies > New policy, name it
PIM-Lab-Context. Include onlypim-operator; explicitly exclude established emergency accounts. Under Target resources, select Authentication context and the new context. - Under Grant, require an authentication strength that the operator can satisfy (for example phishing-resistant MFA after a passkey/FIDO2 or other suitable method is provisioned). Do not require a compliant device unless the lab has one.
- Start in Report-only, inspect sign-in/What If evaluation, then enable On only after verifying recovery and a working method. Report-only does not enforce the control and cannot prove an activation is blocked.
- As PRA edit the Reports Reader PIM activation policy, choose On activation, require Microsoft Entra Conditional Access authentication context, and select the context. Activate as operator in a fresh session, satisfy the policy, approve, then deactivate.
- Negative test: use a session without the required strength and verify the challenge/block in sign-in details. Record the Conditional Access result and context claim. If a valid claim already exists, another prompt may not occur. Restore the original PIM authentication-context rule before disabling/deleting the CA policy and context.
Automation / bind an already-created and tested context
This block performs only the PIM-side binding. Creating a CA policy requires a separately authorized identity-security change; the optional block below shows that equivalent. Authentication-method registration remains a prerequisite. Do not confuse a PIM binding with deployment of an enforcing CA policy.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleManagementPolicy.ReadWrite.Directory'
)
$DirectoryPolicyId = Read-Host 'Reports Reader policy ID saved in module 03'
$ContextId = Read-Host 'Published and tested lab authentication context ID, for example c12'
if ($ContextId -notmatch '^c([1-9]|1[0-9]|2[0-5])$') { throw 'Expected an authentication context ID c1-c25.' }
$uri = "$Graph/policies/roleManagementPolicies/$DirectoryPolicyId/rules/AuthenticationContext_EndUser_Assignment"
$original = Invoke-MgGraphRequest -Method GET -Uri $uri
Save-LabBaseline 'authentication-context-rule-baseline.json' @{
tenantId=$TenantId; policyId=$DirectoryPolicyId; rule=$original
}
Write-LabGraph -Method PATCH -Uri $uri -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyAuthenticationContextRule'
id='AuthenticationContext_EndUser_Assignment'
target=$original.target; isEnabled=$true; claimValue=$ContextId
}
Automation / create a lab context and operator-only CA policy
Only run this as a separately authorized Conditional Access Administrator. Supply that lab actor's ID, two existing emergency-account IDs to exclude, and an unused context ID. The example uses the built-in phishing-resistant authentication strength. Register a qualifying method before enabling enforcement. The policy is created report-only; you must inspect evaluation and deliberately enable it in the portal. Do not run if the names or context already exist.
$CaAdminId = Read-Host 'Object ID of authorized lab Conditional Access Administrator'
Connect-LabGraph -ExpectedUserId $CaAdminId -Scopes @(
'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess', 'AuthenticationContext.ReadWrite.All'
)
$ContextId = Read-Host 'Unused c1-c25 context ID'
if ($ContextId -notmatch '^c([1-9]|1[0-9]|2[0-5])$') { throw 'Invalid context ID.' }
$emergencyIds = @(
(Read-Host 'Existing emergency-account object ID 1'),
(Read-Host 'Existing emergency-account object ID 2')
)
if ($OperatorId -in $emergencyIds -or $emergencyIds -contains '') {
throw 'Invalid emergency exclusions.'
}
$contexts = @(Get-LabGraphItems "$Graph/identity/conditionalAccess/authenticationContextClassReferences")
if ($ContextId -in $contexts.id) { throw 'Context ID already exists. Do not overwrite.' }
Write-LabGraph -Method PATCH -Uri "$Graph/identity/conditionalAccess/authenticationContextClassReferences/$ContextId" -Body @{
displayName='PIM-Lab-StrongAuth'
description='Disposable operator-only PIM activation control'; isAvailable=$true
}
# Run the next write only after context creation has actually succeeded.
$policies = @(Get-LabGraphItems "$Graph/identity/conditionalAccess/policies")
if ('PIM-Lab-Context' -in $policies.displayName) { throw 'Lab policy name already exists.' }
$policy = Write-LabGraph -Uri "$Graph/identity/conditionalAccess/policies" -Body @{
displayName='PIM-Lab-Context'; state='enabledForReportingButNotEnforced'
conditions=@{
users=@{includeUsers=@($OperatorId); excludeUsers=$emergencyIds}
applications=@{includeAuthenticationContextClassReferences=@($ContextId)}
clientAppTypes=@('all')
}
grantControls=@{
operator='AND'
authenticationStrength=@{id='00000000-0000-0000-0000-000000000004'}
}
}
if ($policy) {
$policy | ConvertTo-Json -Depth 30 |
Set-Content (Join-Path $Evidence 'lab-ca-policy.json') -Encoding utf8
Write-Host "Record CA policy ID $($policy.id); report-only is NOT enforcement."
}
References: PIM authentication contextContext ruleCreate/update contextCreate CA policyAuthentication strengths
Review privileged access and apply decisions
- As PRA open PIM > Microsoft Entra roles > Access reviews > New. Name it
PIM-Lab-ReportsReader-Review. Choose only Reports Reader, a one-time review beginning today, and a short supported review period (for example one day). Include the intended eligible/active assignment types rather than assuming a default. - Choose Selected users as reviewers and specify
pim-approver. Inspect self-review and manager/fallback options, but use an independent named reviewer for the lab. Disable Auto apply results; choose No change when reviewers do not respond. Enable justification, reminders, and email as appropriate. Create. - As reviewer use the review email or the My Access > Access reviews experience to open the role review. Confirm the assignment being reviewed. Approve an entitlement still required; deny only a disposable comparison entitlement. Submit a justification.
- As admin return to the review, inspect results, and Stop the current review instance if you do not want to wait. Select Apply results only after checking all decisions. Verify the denied assignment was removed in the appropriate eligible/active view. A completed review with unapplied decisions has not necessarily revoked access.
- Repeat from PIM > Azure resources > lab subscription > Access reviews for the lab Azure role if offered. Azure review administration is subscription-oriented; do not expect an RG blade to expose the same create action. Filter to the lab principal/role/scope where supported. If the portal cannot isolate the lab assignment safely, inspect without creating the review.
- For the PIM for Groups access-review preview, use an authorized Identity Governance Administrator: ID Governance > Access reviews > New access review > Review access to a resource type > Teams + Groups > Select Teams + groups. Select only the PIM-managed lab group, with user scope Everyone. The documented preview includes eligible and active group members; verify both in the generated review. Do not assume it also certifies eligible ownership. If choosing group owners as reviewers, specify a fallback reviewer; only active owners are assigned. Check Governance/Suite licensing and preview availability.
- Inspect recurring review settings (weekly/monthly/quarterly), multi-stage options if licensed, recommendations, fallback reviewers, and the difference between updating a current instance and a series. Do not leave an unnecessary recurring review running after the lab.
Automation / inspect portal-created reviews and complete an instance
Use Graph for directory/group review objects; Azure resource reviews use a different ARM API. The following retrieves the exact review created in the portal, including its scope. This preserves the service-generated assignment-type query rather than guessing that a standard group review covers eligibility.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('AccessReview.ReadWrite.All')
$reviews = @(Get-LabGraphItems "$Graph/identityGovernance/accessReviews/definitions")
$review = @($reviews | Where-Object displayName -eq 'PIM-Lab-ReportsReader-Review')
if ($review.Count -ne 1) { throw 'Expected exactly one portal-created lab review.' }
$ReviewId = $review[0].id
$definition = Invoke-MgGraphRequest -Method GET `
-Uri "$Graph/identityGovernance/accessReviews/definitions/$ReviewId"
$definition | ConvertTo-Json -Depth 40 |
Set-Content (Join-Path $Evidence 'directory-review-definition.json') -Encoding utf8
$definition.scope | ConvertTo-Json -Depth 20
Get-LabGraphItems "$Graph/identityGovernance/accessReviews/definitions/$ReviewId/instances" |
Select-Object id, status, startDateTime, endDateTime
# Record one exact instance ID. Do not select "the first" instance of a series.
Automation / create a one-time privileged-role review directly
Use this instead of portal creation, not as an additional copy. The scope covers users and groups assigned the single Reports Reader role, including eligible and active assignments, using Microsoft's privileged-role review scope model. Inspect every assignee first; this is only safe in the disposable tenant. It is not a generic group-membership review.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('AccessReview.ReadWrite.All')
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId from module 02'
$body = @{
displayName='PIM-Lab-ReportsReader-Review'
descriptionForAdmins='Disposable role certification exercise'
descriptionForReviewers='Review only lab assignments. Provide a reason.'
scope=@{
'@odata.type'='#microsoft.graph.principalResourceMembershipsScope'
principalScopes=@(
@{'@odata.type'='#microsoft.graph.accessReviewQueryScope'; query='/users'; queryType='MicrosoftGraph'},
@{'@odata.type'='#microsoft.graph.accessReviewQueryScope'; query='/groups'; queryType='MicrosoftGraph'}
)
resourceScopes=@(@{
'@odata.type'='#microsoft.graph.accessReviewQueryScope'
query="/roleManagement/directory/roleDefinitions/$DirectoryRoleId"
queryType='MicrosoftGraph'
})
}
reviewers=@(@{query="/users/$ApproverId"; queryType='MicrosoftGraph'})
settings=@{
autoApplyDecisionsEnabled=$false; defaultDecisionEnabled=$false; defaultDecision='None'
justificationRequiredOnApproval=$true; mailNotificationsEnabled=$true
reminderNotificationsEnabled=$true; recommendationsEnabled=$false
instanceDurationInDays=1
recurrence=@{
pattern=@{type='daily'; interval=1}
range=@{type='numbered'; startDate=[datetime]::UtcNow.ToString('yyyy-MM-dd'); numberOfOccurrences=1}
}
}
}
Write-LabGraph -Uri "$Graph/identityGovernance/accessReviews/definitions" -Body $body
# Reviewer terminal: only decide an item assigned to you.
Connect-LabGraph -ExpectedUserId $ApproverId -Scopes @('AccessReview.ReadWrite.All')
$ReviewId = Read-Host 'Exact lab review definition ID'
$InstanceId = Read-Host 'Exact current instance ID'
$base = "$Graph/identityGovernance/accessReviews/definitions/$ReviewId/instances/$InstanceId"
Get-LabGraphItems "$base/decisions" |
Select-Object id, decision, principal, resource
$DecisionId = Read-Host 'Decision ID for the exact lab assignment'
$decision = Read-Host 'Approve or Deny'
if ($decision -notin @('Approve','Deny')) { throw 'Invalid decision.' }
Write-LabGraph -Method PATCH -Uri "$base/decisions/$DecisionId" `
-Body @{decision=$decision; justification='PIM-LAB: reviewed continuing business need'}
# ADMIN terminal: reconnect separately, reconstruct $base with the SAME IDs.
# Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('AccessReview.ReadWrite.All')
# Write-LabGraph -Uri "$base/stop"
# Inspect all decisions and status, then explicitly apply:
# Write-LabGraph -Uri "$base/applyDecisions"
# Re-read the instance until Applied; verify actual assignment removal.
Automation / Azure review definition (ARM preview)
As resource administrator, inspect the subscription review created in the portal. For a scripted equivalent, clone only its properties.scope, reviewers, and settings after auditing that the scope excludes all non-lab assignments. Do not route Azure review requests to Graph.
Connect-LabAzure -ExpectedUserId $AdminId
$base = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/accessReviewScheduleDefinitions"
$AzureReviewId = Read-Host 'Exact Azure lab review definition GUID from the portal'
$review = Read-LabArm "$base/$AzureReviewId`?api-version=2021-12-01-preview"
$review | ConvertTo-Json -Depth 40 |
Set-Content (Join-Path $Evidence 'azure-review-definition.json') -Encoding utf8
$review.properties.scope | ConvertTo-Json -Depth 20 | Write-Host
# Optional create alternative, only after validating the captured scope:
if ((Read-Host 'Create a second review with this exact verified lab scope? Type CREATE') -ceq 'CREATE') {
$settings = $review.properties.settings
$settings.autoApplyDecisionsEnabled = $false
$settings.defaultDecisionEnabled = $false
$settings.Remove('defaultDecision') # ARM does not use Graph's 'None' enum value.
$settings.recurrence = @{
pattern=@{type='weekly'; interval=1}
range=@{type='numbered'; startDate=[datetime]::UtcNow.ToString('o'); numberOfOccurrences=1}
}
$settings.instanceDurationInDays = 1
$newId = [guid]::NewGuid().ToString()
Write-LabArm -Path "$base/$newId`?api-version=2021-12-01-preview" -Body @{
displayName='PIM-Lab-Azure-Review-Automated'
descriptionForAdmins='Disposable Azure role review'
descriptionForReviewers='Review only the isolated lab assignment'
scope=$review.properties.scope; reviewers=$review.properties.reviewers
settings=$settings
}
}
# Stop/apply Azure review instances through the portal in this lab.
# Graph review instance actions above do NOT operate on these ARM resources.
References: Create privileged-role reviewsComplete/apply reviewsPIM group review previewPrivileged-role scope modelCreate Graph definitionsReviewer decisionApply decisionsAzure review API
Investigate alerts and remediate deliberately
- Open directory-role Alerts. Inspect available detections, for example excessive privileged assignments, unused roles, roles assigned outside PIM, and roles that do not require MFA. Different tenants may show different incident counts. Do not create a Global Administrator merely to trigger an alert.
- Select one alert and inspect definition, severity, affected users/roles, incident details, and suggested remediation. Record the relevant lab object and why the rule fired. If there are no incidents, save the empty state and explain the rule rather than manufacturing a risky condition.
- Open alert Settings and inspect enabled state and thresholds. Capture the current value; do not disable broad detections to make the dashboard green. If demonstrating a threshold change in the disposable tenant, restore it immediately afterward.
- For a lab-only incident, review the exact proposed remediation, execute it in the portal, and verify the actual assignment/policy. A dismiss action is not remediation. Refresh/rescan and confirm whether the incident reappears.
- Inspect the Azure resource alert experience separately where exposed. Do not assume directory security alert APIs cover Azure roles or group alerts. Notifications are also not the same feature as security alerts.
Automation / list, inspect, refresh, optionally remediate
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('RoleManagementAlert.ReadWrite.Directory')
$base = "$GraphBeta/identityGovernance/roleManagementAlerts"
$f = [uri]::EscapeDataString("scopeId eq '/' and scopeType eq 'DirectoryRole'")
$alerts = @(Get-LabGraphItems "$base/alerts?`$filter=$f")
$alerts | Select-Object id, alertDefinitionId, isActive, incidentCount
$alerts | ConvertTo-Json -Depth 20 |
Set-Content (Join-Path $Evidence 'pim-alerts.json') -Encoding utf8
$AlertId = Read-Host 'Exact alert ID to inspect (not an incident ID)'
$alert = Invoke-MgGraphRequest -Method GET `
-Uri "$base/alerts/$AlertId`?`$expand=alertDefinition,alertConfiguration,alertIncidents"
$alert | ConvertTo-Json -Depth 30 | Write-Host
# Rescan is asynchronous. Inspect response Location header and operation status.
Write-LabGraph -Uri "$base/alerts/$AlertId/refresh"
# Optional remediation removes/changes access according to the alert definition.
# First verify the incident refers ONLY to a disposable lab assignment:
# $IncidentId = Read-Host 'Verified lab incident ID'
# Write-LabGraph -Uri "$base/alerts/$AlertId/alertIncidents/$IncidentId/remediate"
#
# Deliberately NOT run: PATCH alert {isActive:false}.
# Dismissal can stop scanning this alert type; it is not an access fix.
Automation / inspect and change only a documented configuration field
# Same admin connection. Exact configuration ID comes from the inspected alert.
$ConfigId = Read-Host 'Exact alert configuration ID'
$uri = "$GraphBeta/identityGovernance/roleManagementAlerts/alertConfigurations/$ConfigId"
$config = Invoke-MgGraphRequest -Method GET -Uri $uri
Save-LabBaseline 'alert-configuration-baseline.json' @{
tenantId=$TenantId; configId=$ConfigId; configuration=$config
}
$config | ConvertTo-Json -Depth 20 | Write-Host
# Example ONLY for the too-many-global-admins configuration type.
# Do not apply a threshold payload to a different alert configuration.
if ($config.'@odata.type' -ne '#microsoft.graph.tooManyGlobalAdminsAssignedToTenantAlertConfiguration') {
throw 'This teaching example is only for the too-many-global-admins type.'
}
$threshold = [int](Read-Host 'Approved lab global-admin count threshold (do not add any admins)')
Write-LabGraph -Method PATCH -Uri $uri -Body @{
'@odata.type'=$config.'@odata.type'; isEnabled=$true
globalAdminCountThreshold=$threshold
percentageOfGlobalAdminsOutOfRolesThreshold=$config.percentageOfGlobalAdminsOutOfRolesThreshold
}
# Restore the recorded isEnabled and both thresholds after inspecting the result.
References: Portal alerts and reader rolesBeta alert lifecycle, configuration, and remediation
Audit, correlate and retain evidence
- Open PIM > Microsoft Entra roles > Resource audit for admin history, and My audit for your own activity. Filter to the lab time window and operator. Find eligibility creation, activation request, approval/denial, completed activation, deactivation, and policy updates.
- Open a record and inspect actor, target resources, activity name, result, correlation ID, and additional details. Correlate by the activation request ID where exposed (for example
RoleAssignmentRequestId), user, role, scope, and UTC timestamps; a correlation ID alone is not guaranteed to match every cross-service event. - Open Entra ID > Monitoring & health > Audit logs and inspect the same window. For groups, inspect the PIM request and membership/ownership changes. Compare Sign-in logs > Conditional Access for the authentication-context test.
- In Azure portal open rg-pim-lab > Activity log. Find schedule requests and the tag update by operator. Audit evidence of permission use is different from evidence that activation was approved.
- Export a sanitized evidence set. For longer retention open the Entra Diagnostic settings experience and route the required audit/sign-in categories to an approved Log Analytics workspace or storage. Configure Azure Activity log diagnostic export separately. Do not assume PIM's interactive audit view is permanent storage; documented directory PIM history is 30 days.
- If using a SIEM, alert on high-risk role activations, policy/approver changes, unexpected direct group membership, and privileged assignments outside the approved process. Validate a known lab event arrives before considering export complete. Retention and ingestion can incur cost.
Automation / Graph audit export and ARM Activity log
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('AuditLog.Read.All')
$since = [datetime]::UtcNow.AddHours(-8).ToString('yyyy-MM-ddTHH:mm:ssZ')
$f = [uri]::EscapeDataString("activityDateTime ge $since")
$events = @(Get-LabGraphItems "$Graph/auditLogs/directoryAudits?`$filter=$f")
$labEvents = @($events | Where-Object {
$_.initiatedBy.user.id -in @($AdminId,$OperatorId,$ApproverId) -or
@($_.targetResources | Where-Object { $_.id -in @($OperatorId,$GroupId) }).Count -gt 0
})
$labEvents | ConvertTo-Json -Depth 35 |
Set-Content (Join-Path $Evidence 'directory-audit.json') -Encoding utf8
$labEvents | Select-Object activityDateTime, activityDisplayName, result, correlationId
# The bounded raw set can include unrelated lab-actor activity. Sanitize before sharing.
Connect-LabAzure -ExpectedUserId $AdminId
$start = [datetime]::UtcNow.AddHours(-8).ToString('yyyy-MM-ddTHH:mm:ssZ')
$end = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$f = [uri]::EscapeDataString("eventTimestamp ge '$start' and eventTimestamp le '$end' and resourceGroupName eq '$RgName'")
$path = "/subscriptions/$SubscriptionId/providers/microsoft.insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$f"
$events = @(Get-LabArmItems $path)
$events | ConvertTo-Json -Depth 35 |
Set-Content (Join-Path $Evidence 'azure-activity.json') -Encoding utf8
$events | Select-Object eventTimestamp, caller, operationName, status, correlationId
Automation / example queries after diagnostic export
// Entra diagnostic export must populate AuditLogs first.
// Replace the object ID and bound the time range to the actual exercise.
AuditLogs
| where TimeGenerated > ago(8h)
| where Category =~ "RoleManagement"
| mv-expand Target = TargetResources
| where tostring(Target.id) == "REPLACE_WITH_OPERATOR_OBJECT_ID"
| project TimeGenerated, ActivityDisplayName, Result, CorrelationId,
InitiatedBy, Target, AdditionalDetails
| order by TimeGenerated asc
// Separate Azure Activity log export populates AzureActivity.
AzureActivity
| where TimeGenerated > ago(8h)
| where ResourceGroup =~ "rg-pim-lab"
| where OperationNameValue has "Microsoft.Authorization"
or OperationNameValue has "Microsoft.Resources/tags"
| project TimeGenerated, Caller, OperationNameValue, ActivityStatusValue, CorrelationId
| order by TimeGenerated asc
References: PIM audit and retentionGraph audit APIAzure Activity log APIDiagnostic export
Scoped roles, ABAC and workload boundaries
These are bounded extension exercises, not permissions to broaden the core lab. Custom role authoring, storage deployment, and entitlement-management design are separate technologies; PIM governs their supported assignment lifecycles.
A / Administrative-unit-scoped directory role
- As authorized admin create a new ordinary administrative unit
AU-PIM-Labunder Entra ID > Roles & admins > Administrative units (or search "Administrative units"). Add only disposable target users. Do not use a restricted-management AU for group PIM experiments. - In PIM > Microsoft Entra roles > Add assignments, choose a role that supports AU scope, such as User Administrator. Set scope to the lab AU, choose the operator, and use short time-bound eligibility. Do not use Reports Reader as a substitute if it does not support AU scoping.
- Activate at the AU scope, subject to that role's activation policy. Test an allowed update to a disposable AU member and a denied equivalent operation against a non-AU user. Record both. Do not test by altering administrator or recovery accounts.
- Remove the eligibility and any active assignment, then delete only the empty lab AU. Membership in an AU is not the same as group membership and does not by itself grant privilege.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'RoleManagement.Read.Directory','RoleEligibilitySchedule.ReadWrite.Directory'
)
$AuId = Read-Host 'Object ID of the disposable AU-PIM-Lab administrative unit'
$f = [uri]::EscapeDataString("displayName eq 'User Administrator'")
$roles = @(Get-LabGraphItems "$Graph/roleManagement/directory/roleDefinitions?`$filter=$f")
if ($roles.Count -ne 1) { throw 'Expected one User Administrator role.' }
$body = @{
action='adminAssign'; principalId=$OperatorId; roleDefinitionId=$roles[0].id
directoryScopeId="/administrativeUnits/$AuId"
justification='PIM-LAB: AU-scoped administration'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='afterDuration'; duration='PT1H'}
}
}
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleEligibilityScheduleRequests" -Body $body
# SelfActivate uses roleAssignmentScheduleRequests with this SAME role and AU scope.
# Remove with adminRemove at this SAME scope; do not substitute directoryScopeId='/'.
B / Azure ABAC on an eligible storage data role
- Only in a disposable storage account, create two blob containers
pim-lab-allowedandpim-lab-denied, each with a harmless test blob. Record cost, network settings, and storage account resource ID. Do not open public access or disable a firewall to make the test pass. - At the storage account's Access control (IAM) > Add role assignment, choose Storage Blob Data Reader, operator, Eligible, and an expiry. In Conditions, use the visual condition builder to limit relevant blob read operations to the allowed container. PIM eligible Azure assignments support conditions for supported roles/actions; this is not available for every role.
- Activate and test an actual blob download using Microsoft Entra user account authentication, not an account key or SAS. The allowed blob should succeed, the denied blob should fail. Portal browsing/listing can need additional management-plane access and does not itself prove a blob-read condition.
- Inspect the stored condition string/version via ARM. Reapply the same condition with a schedule request if testing automation. Remove both eligibility and activation and delete only your disposable storage resources.
Connect-LabAzure -ExpectedUserId $AdminId
$StorageScope = Read-Host 'Full resource ID of the disposable lab storage account'
if (-not $StorageScope.StartsWith("$AzureScope/providers/Microsoft.Storage/storageAccounts/",
[StringComparison]::OrdinalIgnoreCase)) { throw 'Storage account must be inside rg-pim-lab.' }
$RoleId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"
# Read a condition you already built and tested in the portal. Never use a guessed
# condition for a different action/suboperation or silently drop it on renewal.
$f = [uri]::EscapeDataString("principalId eq '$OperatorId'")
$items = @(Get-LabArmItems "$StorageScope/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=$f")
$items | ConvertTo-Json -Depth 20 | Write-Host
$InstanceName = Read-Host 'Exact storage data-role eligibility instance name'
$item = @($items | Where-Object name -eq $InstanceName)
if ($item.Count -ne 1 -or $item[0].properties.roleDefinitionId -ne $RoleId -or
-not $item[0].properties.condition) { throw 'Expected one tested conditional data-role instance.' }
$body = @{properties=@{
requestType='AdminUpdate'; principalId=$OperatorId; roleDefinitionId=$RoleId
targetRoleEligibilityScheduleId=$item[0].properties.roleEligibilityScheduleId
condition=$item[0].properties.condition; conditionVersion='2.0'
justification='PIM-LAB: preserve tested ABAC condition'
scheduleInfo=@{
startDateTime=[datetime]::UtcNow.ToString('o')
expiration=@{type='AfterDuration'; duration='PT1H'}
}
}}
$name = [guid]::NewGuid().ToString()
Write-LabArm -Path "$StorageScope/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/$name`?api-version=2020-10-01" -Body $body
C / Custom roles and non-human identities: inspect, do not assume
| Scenario | How to reason about it |
|---|---|
| Custom Entra or Azure role | Create only a minimal disposable role through the appropriate custom-role experience. Assign it using the same schedule APIs with its discovered definition ID; verify supported scope and role permissions. PIM does not invent permissions absent from the role definition. |
| Service principal / managed identity | Do not assume it can complete a human self-activation/MFA/approval flow. Eligibility and principal-type support vary by plane. Application-permission automation can administer schedules when authorized, but is not the same as an eligible human activating as themselves. Validate documented principal support rather than granting an app broad directory write permissions. |
| Emergency access | Keep independent, tested recovery access. Monitor exceptions explicitly. Never remove the last administrator or make all approvers depend on the very activation they must approve. |
| Entitlement management / access packages | Can integrate with group-based access, but request/access-package approval and PIM activation approval are different controls. Test which exact entitlement is granted and which activation boundary remains. |
| Microsoft 365 / Intune / SaaS | PIM can govern a supported Entra role or group that confers access. It does not guarantee every workload's authorization cache instantly honors expiry; test the target application. |
D / Custom activation extensions (preview; Governance/Suite required)
P2 alone does not include this preview. It adds real external validation beyond PIM's informational ticket field. Endpoint hosting and app authentication are prerequisites, not deployed by this document. Use only a lab-owned HTTPS API with signature, issuer, audience, lifetime, and authorized PIM-caller validation through a trusted authentication library or platform. Never log bearer tokens or accept unsigned JSON as authenticated identity.
- Prepare an authenticated test API and single-tenant app registration. Under App registrations > your API > Expose an API, configure the documented Application ID URI matching the endpoint host and ending with the app's client ID. Verify an unauthenticated POST is rejected before connecting PIM.
- As PRA open PIM > Custom Extensions > Create a custom extension. Name it
PIM-Lab-TicketCheck, choose Groups and Pre-approval, enter the HTTPS URL, timeout/retries, API authentication registration, and attributeprojectCode. Create. - As active group owner open PIM > Groups > lab group > Settings > Member > Edit. Link the pre-approval extension. Begin in Audit mode; confirm requests reach the endpoint without treating audit mode as enforcement.
- Configure the test API to reject an invalid project/ticket and allow the known lab case. Turn off Audit mode only for the test group. Demonstrate denial, then normal approval. Correlate
evaluationIdwith PIM audit. Unlink before deleting the extension and its hosting assets.
Outcome semantics: pre-approval Approved continues the normal manual-approval path; Denied blocks; AutoApproved skips manual approval and is deliberately not used here. A post-approval extension runs after human approval and may return approved, denied, or deferred. A deferred request remains pending external evaluation until the registered extension app sends a final /updateRequest callback. That callback cannot defer again; use provider-specific payloads and authentication, not an operator's interactive token.
Automation / register an existing secured API with PIM
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('PrivilegedAccess-CustomExt.ReadWrite.All')
$Endpoint = [uri](Read-Host 'Your own authenticated test API HTTPS URL')
$ApiResourceId = Read-Host 'App registration Application ID URI'
if ($Endpoint.Scheme -ne 'https' -or
-not $ApiResourceId.StartsWith("api://$($Endpoint.Host)/")) {
throw 'Use an HTTPS endpoint and the matching registered Application ID URI.'
}
$body = @{
'@odata.type'='#microsoft.graph.roleManagementCustomCalloutExtension'
displayName='PIM-Lab-TicketCheck'; description='Disposable external validation lab'
resourceType='entraGroups'; type='preApproval'; isEnabled=$true
customAttributes=@('projectCode')
endpointConfiguration=@{
'@odata.type'='#microsoft.graph.httpRequestEndpoint'; targetUrl=$Endpoint.AbsoluteUri
}
clientConfiguration=@{
'@odata.type'='#microsoft.graph.customExtensionClientConfiguration'
timeoutInMilliseconds=10000; maximumRetries=1
}
authenticationConfiguration=@{
'@odata.type'='#microsoft.graph.azureAdTokenAuthentication'; resourceId=$ApiResourceId
}
}
$extension = Write-LabGraph -Uri "$GraphBeta/identityGovernance/privilegedAccess/customExtensions" -Body $body
if ($extension) {
$extension | ConvertTo-Json -Depth 20 |
Set-Content (Join-Path $Evidence 'lab-custom-extension.json') -Encoding utf8
Write-Host "Record custom extension ID: $($extension.id)"
}
Automation / discover the exact stage rule and bind it
The script does not guess stage rule IDs. Inspect returned names/targets and choose the pre-approval Member activation rule. Portal Audit mode is configured separately; use the portal-first test before enabling an enforcing rule.
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('RoleManagementPolicy.ReadWrite.AzureADGroup')
$GroupId = Read-Host 'Verified lab group object ID'
$ExtensionId = Read-Host 'Lab pre-approval Groups extension ID'
$f = [uri]::EscapeDataString("scopeId eq '$GroupId' and scopeType eq 'Group'")
$pas = @(Get-LabGraphItems "$Graph/policies/roleManagementPolicyAssignments?`$filter=$f")
$pa = @($pas | Where-Object roleDefinitionId -eq 'member')
if ($pa.Count -ne 1) { throw 'Expected one Member policy.' }
$base = "$Graph/policies/roleManagementPolicies/$($pa[0].policyId)/rules"
$rules = @(Get-LabGraphItems $base | Where-Object {
$_.'@odata.type' -eq '#microsoft.graph.unifiedRoleManagementPolicyCustomExtensionRule'
})
$rules | Select-Object id, displayName, isEnabled, customExtensionId, target
$RuleId = Read-Host 'Exact PRE-APPROVAL activation rule ID after checking stage and target'
$rule = @($rules | Where-Object id -eq $RuleId)
if ($rule.Count -ne 1) { throw 'No unique custom-extension rule selected.' }
Save-LabBaseline 'group-custom-extension-rule-baseline.json' @{
tenantId=$TenantId; policyId=$pa[0].policyId; rule=$rule[0]
}
Write-LabGraph -Method PATCH -Uri "$base/$RuleId" -Body @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyCustomExtensionRule'
target=$rule[0].target; customExtensionId=$ExtensionId; isEnabled=$true
}
# Add the custom input to the module 10 activation body:
# $body.customData = (@{projectCode='PIM-LAB'} | ConvertTo-Json -Compress)
# Use the existing group assignmentScheduleRequests endpoint and policy controls.
# Before cleanup, restore this rule's original writable fields, then DELETE only
# the saved lab extension at /beta/identityGovernance/privilegedAccess/customExtensions/{id}.
Post-approval deferred callback: API contract, not an interactive-user shortcut
The extension service, authenticated as its registered application, resolves the exact pending request. Directory callback: Graph v1.0 /roleManagement/directory/roleAssignmentScheduleRequests/{requestId}/updateRequest. Groups callback: Graph beta /identityGovernance/privilegedAccess/group/assignmentScheduleRequests/{requestId}/updateRequest. Azure callback: ARM {scope}/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/{requestId}/updateRequest?api-version=2020-10-01. Graph accepts a string reason; ARM accepts a string collection. Keep the original evaluation ID, use a final approved/denied outcome, inspect the returned status, and verify provisioning. Repeated callbacks, retries, and endpoint failures need explicit idempotency/error handling in the hosted service.
References: AU-scoped role assignmentPIM and ABACCondition propertiesSupported PIM boundariesCustom extensions previewCreate extension APIBind extension ruleDirectory evaluation callback
Teardown and verify no residual privilege
- As each requester, cancel outstanding lab activation requests and deactivate directory, Azure, group Member, and group Owner activations. Confirm no request remains PendingApproval and no lab activated schedule is still active.
- As PRA, remove only the lab operator's Reports Reader eligibility, optional comparison assignments, AU-scoped assignments, and the lab group's directory-role binding. Inspect eligible and active-assigned tabs separately. Do not remove the admin's PRA or the recovery administrator.
- Remove the lab Azure eligible/active-assigned schedules at their exact resource scope, including any optional storage role. Check inherited and group-based paths. A deleted request history entry does not remove a schedule.
- Stop/delete lab-only review definitions/series so no future review runs. Preserve audit evidence. Restore any changed alert thresholds.
- Restore Reports Reader role rules and any other changed role policies from their original baselines, including notification recipients and authentication context. In the CA lab, restore the PIM rule first, then disable/delete only
PIM-Lab-Contextand delete the unused lab context. Unlink any custom extension and resolve/cancel deferred lab requests before deleting the exact extension, app registration, and hosted test API. Keep all pre-existing CA policies and authentication methods intact. - Remove eligible/active group schedules and the group-role binding. Delete the new lab group only after checking it is unused. It cannot be "unmanaged" from PIM. Remove optional custom roles/AU after their assignments are gone.
- Inspect
rg-pim-labfor unexpected or shared resources. Delete only the empty lab resource group or the explicitly enumerated disposable resources you created. Do not delete a group that contains another person's resources. - If you created users solely for this lab, delete only those test accounts after removing their assignments. Reclaim their licenses as appropriate. Review and remove only the delegated consent grants introduced specifically for the lab; never revoke a shared organization's consent blindly. Disconnect sessions and secure/archive evidence.
Automation / restore the saved directory policy rules
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('RoleManagementPolicy.ReadWrite.Directory')
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId'
$backup = Get-Content (Join-Path $Evidence "directory-$DirectoryRoleId-baseline.json") -Raw |
ConvertFrom-Json -AsHashtable
if ($backup.tenantId -ne $TenantId) { throw 'Baseline belongs to a different tenant.' }
$base = "$Graph/policies/roleManagementPolicies/$($backup.policyId)/rules"
foreach ($rule in $backup.rules) {
$body = @{}
$writable = @('@odata.type','target','isExpirationRequired','maximumDuration',
'enabledRules','setting','claimValue','isEnabled','notificationType',
'recipientType','notificationLevel','isDefaultRecipientsEnabled',
'notificationRecipients','customExtensionId')
foreach ($key in $writable) {
if ($rule.ContainsKey($key)) {
$body[$key] = $rule[$key]
}
}
Write-LabGraph -Method PATCH -Uri "$base/$($rule.id)" -Body $body
}
# Read rules again and compare the writable fields to the baseline.
# If an API rejects read-only metadata, restore that rule using the portal
# against the saved values; do not discard an unexplained error.
Automation / remove the specific directory eligibility
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @('RoleEligibilitySchedule.ReadWrite.Directory')
$DirectoryRoleId = Read-Host 'Reports Reader roleDefinitionId'
Write-LabGraph -Uri "$Graph/roleManagement/directory/roleEligibilityScheduleRequests" -Body @{
action='adminRemove'; principalId=$OperatorId; roleDefinitionId=$DirectoryRoleId
directoryScopeId='/'; justification='PIM-LAB: teardown exact operator eligibility'
}
# For active assigned roles use roleAssignmentScheduleRequests/adminRemove.
# For the group role bundle use its verified group ID as principalId.
# For AU scope use the original /administrativeUnits/{id}, not '/'.
Automation / remove an exact Azure eligibility and restore policy
Connect-LabAzure -ExpectedUserId $AdminId
$AzureRoleGuid = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
$f = [uri]::EscapeDataString("principalId eq '$OperatorId'")
$items = @(Get-LabArmItems "$AzureScope/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=$f" |
Where-Object {
$_.properties.scope -eq $AzureScope -and
$_.properties.roleDefinitionId.Split('/')[-1] -eq $AzureRoleGuid
})
if ($items.Count -ne 1) { throw 'Expected one exact lab RG eligibility; inspect before removal.' }
$body = @{properties=@{
requestType='AdminRemove'; principalId=$OperatorId
roleDefinitionId=$items[0].properties.roleDefinitionId
targetRoleEligibilityScheduleId=$items[0].properties.roleEligibilityScheduleId
justification='PIM-LAB: teardown'
}}
$name = [guid]::NewGuid().ToString()
Write-LabArm -Path "$AzureScope/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/$name`?api-version=2020-10-01" -Body $body
$backup = Get-Content (Join-Path $Evidence 'azure-contributor-baseline.json') -Raw |
ConvertFrom-Json -AsHashtable
if ($backup.scope -ne $AzureScope -or $backup.tenantId -ne $TenantId) {
throw 'Azure baseline scope/tenant mismatch.'
}
Write-LabArm -Method PATCH -Path "$($backup.policyPath)?api-version=2020-10-01" `
-Body @{properties=@{rules=$backup.rules}}
Automation / remove group eligibilities and delete only the verified lab group
Connect-LabGraph -ExpectedUserId $AdminId -Scopes @(
'PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup',
'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup',
'Group.ReadWrite.All','RoleManagement.ReadWrite.Directory'
)
$GroupId = Read-Host 'Exact lab group ID saved at creation'
$group = Invoke-MgGraphRequest -Method GET -Uri "$Graph/groups/$GroupId"
if ($group.displayName -ne 'PIM-Lab-Readers' -or
$group.description -ne 'Disposable PIM training group') {
throw 'Group identity check failed. Verify manually, do not weaken the guard.'
}
$f = [uri]::EscapeDataString("groupId eq '$GroupId'")
$base = "$Graph/identityGovernance/privilegedAccess/group"
foreach ($kind in @('assignmentScheduleInstances','eligibilityScheduleInstances')) {
$instances = @(Get-LabGraphItems "$base/$kind`?`$filter=$f")
$instances | ConvertTo-Json -Depth 20 | Write-Host
}
# Remove only operator relationships after separately deactivating active access.
# Set $access to member or owner only if that specific eligibility exists:
$access = Read-Host 'Exact eligible operator relationship to remove: member or owner'
if ($access -notin @('member','owner')) { throw 'Invalid relationship.' }
Write-LabGraph -Uri "$base/eligibilityScheduleRequests" -Body @{
action='adminRemove'; accessId=$access; principalId=$OperatorId; groupId=$GroupId
justification='PIM-LAB: teardown eligible relationship'
}
# Verify all group-role bindings and schedules are gone before deletion.
if ((Read-Host 'Confirm all lab bindings removed; type DELETE GROUP') -ceq 'DELETE GROUP') {
Write-LabGraph -Method DELETE -Uri "$Graph/groups/$GroupId"
}
Automation / empty resource-group deletion only
Connect-LabAzure -ExpectedUserId $AdminId
$rg = Read-LabArm "$AzureScope`?api-version=2021-04-01"
if ($rg.name -ne 'rg-pim-lab' -or $rg.tags.Purpose -ne 'PIMLab') {
throw 'Resource group is not the expected tagged lab group.'
}
$resources = @(Get-LabArmItems "$AzureScope/resources?api-version=2021-04-01")
if ($resources.Count -ne 0) {
$resources | Select-Object id, type, name
throw 'Resource group is not empty. Review and delete exact disposable assets manually.'
}
if ((Read-Host 'Type the full resource-group ID to confirm deletion') -cne $AzureScope) {
throw 'Deletion not confirmed.'
}
Write-LabArm -Method DELETE -Path "$AzureScope`?api-version=2021-04-01"
# ARM deletion is asynchronous. Poll GET until 404 and confirm in the portal.
# Disconnect only after the verification below is complete.
After verification: Disconnect-MgGraph and Disconnect-AzAccount -Scope Process. The lab intentionally does not auto-delete local evidence or browser notes; these can contain tenant/user identifiers. Retain or remove them according to your policy.
References: Directory revocationAzure removal requestGroup enrollment lifecycle
Troubleshooting and capstone
| Symptom | Check in this order |
|---|---|
| PIM missing / license error | Correct tenant; valid P2/Governance entitlement; license assignment to applicable users; trial expiry; required role and policy restrictions. Consult current licensing rather than inferring entitlement from an Azure subscription. |
| 403 from Graph | Signed-in account ID and tenant; delegated scopes actually consented in Get-MgContext; user's active role or eligible self-action; resource scope; endpoint version. Consent does not replace a user role. |
| 403 from ARM | Azure context and account; role at exact resource scope; deny assignments; ABAC conditions; propagation. Entra PRA/Global Reader does not automatically grant Azure resource administration. |
| MFA required / claims challenge | Use the supported interactive portal or reconnect with an authentication flow able to satisfy the challenge. Check CA sign-in details and allowed methods. Do not disable MFA or swap to an app-only token to bypass a human activation control. |
| No fresh MFA prompt | An earlier qualifying claim may satisfy the requirement. Inspect sign-in/authentication context results; lack of a new prompt alone does not prove a control is disabled. |
| Duration / policy validation error | Activation cap versus eligible/active assignment maximum; UTC timestamps; expired schedule; minimum five-minute constraints; exact scope's policy. Do not silently drop approval, condition, or ticket data. |
| Pending approval | Named approver is a different available user; correct plane's approval blade; request/approval/step IDs; first decision wins. For the documented activation approval workflow, an unanswered request expires after 24 hours; do not assume a configurable multi-stage escalation path. |
| Activated but still denied | Request provisioning status; active schedule/instance; target role actually includes the action; correct tenant/scope; application token/cache refresh; group propagation; other deny/CA/network controls. Read-only access tests may be masked by ordinary user permissions. |
| Access persists after deactivation | Standing direct/inherited/group access; another activation; group ownership/member bypass; app/token caching. PIM assignment removal is not a universal immediate session revocation mechanism. |
| Group policy ID not found | After onboarding, rediscover policy assignments using scopeType Group and exact group ID. Member and Owner each have a separate policy. |
| 429 / timeout / partial policy update | Honor Retry-After; inspect existing request and current policy before retrying. Do not resubmit a create operation blindly. Multi-rule updates may partially succeed; compare every rule with the desired state and saved baseline. |
| No notification | Correct event/recipient rule, recipient mailbox, mail routing/quarantine, policy save, and actual event state. Portal approval can work even if email delivery fails. |
| Review finished but access remains | Scope and assignment type; Completed versus Applied; auto-apply setting; exact denied item; nested or alternate access paths. Do not confuse review completion with effective revocation. |
Capstone / evidence matrix
Use this as the definition of done. Record exact IDs in your secured evidence folder, not passwords/tokens. Progress checkboxes only track reading; they do not query or attest your tenant.
| Capability | Required demonstration | Modules |
|---|---|---|
| Least privilege / scope | Correct actor and tenant; no inherited access masking tests; separate directory/ARM roles | 00-02, 07 |
| Policy configuration | Before/after rules; MFA/context, duration, approval, justification, ticketing, expiry, notifications | 03, 07, 09, 11 |
| Assignment types | Eligible versus active assigned versus activated; permanent toggles inspected without weakening policy | 04, 06 |
| Entra workflow | Approve, deny, cancel, cap rejection, explicit deactivation, natural expiry, successful protected action | 05-06 |
| Azure workflow | RG eligibility, approval, allowed tag write, deactivation, denied write | 08 |
| Group workflow | Member/Owner independence; before/after active relationships; optional role-bundle boundary | 09-10 |
| Lifecycle | Extend, renew, update, revoke; request status distinguished from effective state | 06, 16 |
| Governance | Review scope, decision, Applied state and assignment outcome; alert investigation/rescan | 12-13 |
| Monitoring | Linked request, approver, activation, operation, expiry/deactivation evidence in UTC | 14 |
| Advanced scope / extensions | AU/ABAC allowed and denied tests; optional custom-extension audit/enforcement outcomes, or explicitly marked not run | 15 |
| Cleanup | No residual lab privilege, no recurring reviews, original shared policies restored, assets verified absent | 16 |
Explain it without the portal
- Why is a permanent eligible assignment different from permanent active access? Which clock limits eligibility, and which limits an activation?
- Why can a PRA configure a directory role but still get 403 on Azure role eligibility? What additional authority and API are needed?
- Why does an active role assignment to a group make its membership policy a security boundary? What happens if an owner adds a member directly?
- Why do ticket information, an MFA checkbox, an authentication context, an approver email, and an Applied review status prove different things?
- Which identifiers do you need to correlate request, schedule, instance, approval step, audit record, and actual resource action?
Troubleshooting references: Directory activationAzure activationApproval limitsLicensing requirements
Independent learning material, not a Microsoft product. Documentation reference check: 4 November 2026. Preserve current Microsoft licensing, role, cloud, and preview requirements when adapting. No external fonts, images, libraries, or telemetry are required to read this file.