Private/RoleManagement/Invoke-PIMRoleDeactivation.ps1
|
function Invoke-PIMRoleDeactivation { <# .SYNOPSIS Deactivates selected active PIM roles. .DESCRIPTION Handles the deactivation of active PIM roles including: - Both Entra ID directory roles and PIM-enabled groups - Progress tracking with splash screen - Comprehensive error handling .PARAMETER CheckedItems Array of checked ListView items representing the active roles to deactivate. .PARAMETER Form Reference to the main form for UI updates. .EXAMPLE Invoke-PIMRoleDeactivation -CheckedItems $selectedRoles -Form $mainForm #> [CmdletBinding()] param( [Parameter(Mandatory)] [array]$CheckedItems, [Parameter(Mandatory)] [System.Windows.Forms.Form]$Form ) Write-Verbose "Starting deactivation process for $($CheckedItems.Count) role(s)" # Initialize splash variable $operationSplash = $null $getGroupAccessId = { param($RoleData) $accessCandidates = @() if ($RoleData.PSObject.Properties['AccessId'] -and $RoleData.AccessId) { $accessCandidates += $RoleData.AccessId } if ($RoleData.PSObject.Properties['Assignment'] -and $RoleData.Assignment -and $RoleData.Assignment.PSObject.Properties['AccessId'] -and $RoleData.Assignment.AccessId) { $accessCandidates += $RoleData.Assignment.AccessId } if ($RoleData.PSObject.Properties['MemberType'] -and $RoleData.MemberType) { $accessCandidates += $RoleData.MemberType } if ($RoleData.PSObject.Properties['MembershipType'] -and $RoleData.MembershipType) { $accessCandidates += $RoleData.MembershipType } foreach ($candidate in $accessCandidates) { $normalizedAccessId = ([string]$candidate).Trim().ToLowerInvariant() if ($normalizedAccessId -in @('member', 'owner')) { return $normalizedAccessId } } return 'member' } try { # Confirm deactivation first (before showing splash) $roleNames = @($CheckedItems | ForEach-Object { if ($_.Tag.Scope -and $_.Tag.Scope -ne "Directory") { "$($_.Tag.DisplayName) [$($_.Tag.Scope)]" } else { $_.Tag.DisplayName } }) $message = "Are you sure you want to deactivate the following role(s)?`n`n$($roleNames -join "`n")" $result = Show-TopMostMessageBox -Message $message -Title "Confirm Deactivation" -Buttons YesNo -Icon Question if ($result -ne 'Yes') { Write-Verbose "Deactivation cancelled by user" return } # Show operation splash AFTER user confirms $operationSplash = Show-OperationSplash -Title "Role Deactivation" -InitialMessage "Preparing role deactivation..." -ShowProgressBar $true # ── Build deactivation job list ────────────────────────────────────────── $deactivationErrors = @() $successCount = 0 $totalRoles = @($CheckedItems).Count $currentRole = 0 # Track role data for successfully-submitted deactivations so we can suppress # them in the active list during Graph/ARM propagation lag. $successfullyDeactivated = [System.Collections.ArrayList]::new() # Pre-resolve any missing ScheduleIds (must be done sequentially before batching) $operationSplash.UpdateStatus("Resolving active schedule IDs...", 10) $resolvedJobs = [System.Collections.ArrayList]::new() foreach ($item in $CheckedItems) { $roleData = $item.Tag $jobEntry = [PSCustomObject]@{ Item = $item RoleData = $roleData ScheduleId = $null Error = $null } try { switch ($roleData.Type) { 'Entra' { $sid = if ($roleData.ScheduleId) { $roleData.ScheduleId } else { $active = @(Get-MgRoleManagementDirectoryRoleAssignmentSchedule ` -Filter "principalId eq '$($script:CurrentUser.Id)' and roleDefinitionId eq '$($roleData.RoleDefinitionId)'" ` -ErrorAction SilentlyContinue) if ($active -and $active.Count -gt 0) { $active[0].Id } else { throw "Could not find active assignment schedule for: $($roleData.DisplayName)" } } $jobEntry.ScheduleId = $sid } 'Group' { if (-not $roleData.GroupId) { throw "Missing GroupId for group deactivation: $($roleData.DisplayName)" } $accessId = & $getGroupAccessId $roleData $sid = if ($roleData.ScheduleId) { $roleData.ScheduleId } else { $active = @(Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentSchedule ` -Filter "principalId eq '$($script:CurrentUser.Id)' and groupId eq '$($roleData.GroupId)' and accessId eq '$accessId'" ` -ErrorAction SilentlyContinue) if ($active -and $active.Count -gt 0) { $active[0].Id } else { throw "Could not find active group assignment schedule for: $($roleData.DisplayName). It may not currently be active." } } $jobEntry.ScheduleId = $sid } 'AzureResource' { if (-not $roleData.RoleDefinitionId -or -not $roleData.FullScope) { throw "Missing Azure Resource role details for deactivation: $($roleData.DisplayName)" } # Azure Resource deactivation does not require a pre-fetched ScheduleId } default { throw "Unsupported role type: $($roleData.Type)" } } } catch { $jobEntry.Error = if ($_.Exception.Message) { $_.Exception.Message } else { $_.ToString() } Write-Warning "Pre-resolution failed for $($roleData.DisplayName): $($jobEntry.Error)" } $null = $resolvedJobs.Add($jobEntry) } # Separate into error / Graph / Azure batches $graphJobs = @($resolvedJobs | Where-Object { -not $_.Error -and $_.RoleData.Type -in @('Entra', 'Group') }) $azureJobs = @($resolvedJobs | Where-Object { -not $_.Error -and $_.RoleData.Type -eq 'AzureResource' }) # Add pre-resolution failures directly to deactivationErrors foreach ($failed in @($resolvedJobs | Where-Object { $_.Error })) { $currentRole++ $deactivationErrors += "$($failed.RoleData.DisplayName): $($failed.Error)" } # ── Graph roles: parallel REST (fall back to $batch if no Graph token) ─ if ($graphJobs.Count -gt 0) { $operationSplash.UpdateStatus("Submitting $($graphJobs.Count) Graph deactivation(s) in parallel...", 30) # Try to acquire a Graph access token from the Microsoft.Graph SDK's # own session (it carries the PIM scopes granted by Connect-MgGraph; # an Az-acquired token would lack those scopes and return # PermissionScopeNotGranted). If unavailable, fall through to $batch. $graphTokDeact = $null try { $probe = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me?$select=id' ` -OutputType HttpResponseMessage -ErrorAction Stop if ($probe -and $probe.RequestMessage -and $probe.RequestMessage.Headers -and $probe.RequestMessage.Headers.Authorization -and $probe.RequestMessage.Headers.Authorization.Parameter) { $graphTokDeact = $probe.RequestMessage.Headers.Authorization.Parameter } } catch { Write-Verbose "Could not extract Graph SDK token, will use Invoke-MgGraphRequest `$batch: $($_.Exception.Message)" } $currentUserIdGraph = $script:CurrentUser.Id if ($graphTokDeact) { # Build per-job request specs for parallel execution $graphSpecs = [System.Collections.ArrayList]::new() foreach ($job in $graphJobs) { $rd = $job.RoleData if ($rd.Type -eq 'Entra') { $spec = [PSCustomObject]@{ Job = $job Uri = 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' Body = @{ action = 'selfDeactivate' principalId = $currentUserIdGraph roleDefinitionId = $rd.RoleDefinitionId directoryScopeId = if ($rd.DirectoryScopeId) { $rd.DirectoryScopeId } else { '/' } roleAssignmentScheduleId = $job.ScheduleId justification = 'Deactivated via PowerShell' } } } else { $accessId = & $getGroupAccessId $rd $spec = [PSCustomObject]@{ Job = $job Uri = 'https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/assignmentScheduleRequests' Body = @{ action = 'selfDeactivate' principalId = $currentUserIdGraph groupId = $rd.GroupId accessId = $accessId assignmentScheduleId = $job.ScheduleId justification = 'Deactivated via PowerShell' } } } $null = $graphSpecs.Add($spec) } $graphResults = $graphSpecs | ForEach-Object -ThrottleLimit 5 -Parallel { $spec = $_ $tok = $using:graphTokDeact $bodyTxt = $spec.Body | ConvertTo-Json -Depth 8 -Compress $headers = @{ Authorization = "Bearer $tok" 'Content-Type' = 'application/json' } try { $resp = Invoke-RestMethod -Method Post -Uri $spec.Uri -Headers $headers -Body $bodyTxt -ErrorAction Stop [PSCustomObject]@{ Job = $spec.Job Success = $true Response = $resp Error = $null } } catch { # Try to extract Graph error.message from the response body $errMsg = $_.Exception.Message try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $parsed = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop if ($parsed.error -and $parsed.error.message) { $errMsg = $parsed.error.message } } } catch {} [PSCustomObject]@{ Job = $spec.Job Success = $false Response = $null Error = $errMsg } } } foreach ($r in @($graphResults)) { $currentRole++ $rd = $r.Job.RoleData if ($r.Success) { Write-Verbose "$($rd.Type) role deactivated via parallel REST: $($rd.DisplayName)" $successCount++ $null = $successfullyDeactivated.Add($rd) } else { $deactivationErrors += "$($rd.DisplayName): $($r.Error)" Write-Warning "Parallel deactivation failed for $($rd.DisplayName): $($r.Error)" } } } else { # Fallback: Microsoft Graph $batch API via Invoke-MgGraphRequest $batchBodies = [System.Collections.ArrayList]::new() $batchMeta = [System.Collections.ArrayList]::new() foreach ($job in $graphJobs) { $batchId = "$($batchBodies.Count + 1)" $rd = $job.RoleData if ($rd.Type -eq 'Entra') { $reqBody = @{ action = 'selfDeactivate' principalId = $currentUserIdGraph roleDefinitionId = $rd.RoleDefinitionId directoryScopeId = if ($rd.DirectoryScopeId) { $rd.DirectoryScopeId } else { '/' } roleAssignmentScheduleId = $job.ScheduleId justification = 'Deactivated via PowerShell' } $reqUrl = '/roleManagement/directory/roleAssignmentScheduleRequests' } else { $accessId = & $getGroupAccessId $rd $reqBody = @{ action = 'selfDeactivate' principalId = $currentUserIdGraph groupId = $rd.GroupId accessId = $accessId assignmentScheduleId = $job.ScheduleId justification = 'Deactivated via PowerShell' } $reqUrl = '/identityGovernance/privilegedAccess/group/assignmentScheduleRequests' } $null = $batchBodies.Add(@{ id = $batchId method = 'POST' url = $reqUrl headers = @{ 'Content-Type' = 'application/json' } body = $reqBody }) $null = $batchMeta.Add([PSCustomObject]@{ BatchId = $batchId; Job = $job }) } $BATCH_SIZE = 20 for ($bi = 0; $bi -lt $batchBodies.Count; $bi += $BATCH_SIZE) { $chunkEnd = [Math]::Min($bi + $BATCH_SIZE - 1, $batchBodies.Count - 1) $chunk = @($batchBodies[$bi..$chunkEnd]) $batchBody = @{ requests = $chunk } try { Write-Verbose "Submitting Graph deactivation batch ($($chunk.Count) request(s))" $batchResp = Invoke-MgGraphRequest -Method POST -Uri '$batch' -Body $batchBody -ErrorAction Stop foreach ($resp in @($batchResp.responses)) { $meta = $batchMeta | Where-Object { $_.BatchId -eq $resp.id } | Select-Object -First 1 if (-not $meta) { continue } $currentRole++ if ($resp.status -ge 200 -and $resp.status -lt 300) { Write-Verbose "$($meta.Job.RoleData.Type) role deactivated via batch" $successCount++ $null = $successfullyDeactivated.Add($meta.Job.RoleData) } else { $errMsg = if ($resp.body -and $resp.body.error -and $resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" } $deactivationErrors += "$($meta.Job.RoleData.DisplayName): $errMsg" Write-Warning "Batch deactivation failed for $($meta.Job.RoleData.DisplayName): $errMsg" } } } catch { Write-Warning "Graph batch deactivation failed, falling back to sequential: $($_.Exception.Message)" foreach ($reqItem in $chunk) { $meta = $batchMeta | Where-Object { $_.BatchId -eq $reqItem.id } | Select-Object -First 1 if (-not $meta) { continue } $currentRole++ $rd = $meta.Job.RoleData try { if ($rd.Type -eq 'Entra') { $rb = @{ principalId = $currentUserIdGraph action = 'selfDeactivate' justification = 'Deactivated via PowerShell' roleDefinitionId = $rd.RoleDefinitionId directoryScopeId = if ($rd.DirectoryScopeId) { $rd.DirectoryScopeId } else { '/' } roleAssignmentScheduleId = $meta.Job.ScheduleId } New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $rb -ErrorAction Stop | Out-Null } else { $accessId = & $getGroupAccessId $rd $rb = @{ principalId = $currentUserIdGraph groupId = $rd.GroupId action = 'selfDeactivate' justification = 'Deactivated via PowerShell' accessId = $accessId assignmentScheduleId = $meta.Job.ScheduleId } New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $rb -ErrorAction Stop | Out-Null } Write-Verbose "$($rd.Type) role deactivated via sequential fallback" $successCount++ $null = $successfullyDeactivated.Add($rd) } catch { $errMsg = if ($_.Exception.Message) { $_.Exception.Message } else { $_.ToString() } $deactivationErrors += "$($rd.DisplayName): $errMsg" } } } } } } # ── Azure Resource roles: parallel ARM REST PUT ─────────────────────── if ($azureJobs.Count -gt 0) { $operationSplash.UpdateStatus("Submitting $($azureJobs.Count) Azure Resource deactivation(s) in parallel...", 60) # Acquire ARM token once $armTokDeact = $null try { $azCtxDeact = Get-AzContext -ErrorAction SilentlyContinue if ($azCtxDeact) { $tokenObj = Get-AzAccessToken -ResourceUrl 'https://management.azure.com/' -ErrorAction Stop $armTokDeact = if ($tokenObj.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new('', $tokenObj.Token).Password } else { $tokenObj.Token } } } catch { Write-Verbose "Could not acquire ARM token for parallel deactivation: $($_.Exception.Message)" } # SelfDeactivate always targets the current user, regardless of how the role # is held (direct vs. group-inherited). Azure role objects expose ObjectId # (which may be a group id), not PrincipalId, so we pass the caller id # explicitly via $using:. $currentUserIdDeact = $script:CurrentUser.Id $azureDeactResults = $azureJobs | ForEach-Object -Parallel { $job = $_ $rd = $job.RoleData $tok = $using:armTokDeact $callerId = $using:currentUserIdDeact try { if (-not $tok) { throw "ARM access token unavailable; cannot submit SelfDeactivate request." } if (-not $callerId) { throw "Current user id unavailable; cannot submit SelfDeactivate request." } $requestName = [System.Guid]::NewGuid().ToString() $roleDefId = if ($rd.RoleDefinitionId.StartsWith('/')) { $rd.RoleDefinitionId } else { "$($rd.FullScope)/providers/Microsoft.Authorization/roleDefinitions/$($rd.RoleDefinitionId)" } $bodyObj = @{ properties = @{ roleDefinitionId = $roleDefId principalId = $callerId requestType = 'SelfDeactivate' justification = 'Deactivated via PowerShell' } } $hdrs = @{ 'Authorization' = "Bearer $tok"; 'Content-Type' = 'application/json' } $uri = "https://management.azure.com$($rd.FullScope)/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$($requestName)?api-version=2020-10-01" $bodyJson = $bodyObj | ConvertTo-Json -Depth 8 -Compress $resp = Invoke-RestMethod -Uri $uri -Headers $hdrs -Method Put -Body $bodyJson -ErrorAction Stop [PSCustomObject]@{ Success = $true; Job = $job; Response = $resp } } catch { [PSCustomObject]@{ Success = $false; Job = $job; ErrorMessage = $_.Exception.Message } } } -ThrottleLimit 5 # Best-effort scrub of plaintext bearer token from memory. $armTokDeact = $null foreach ($azResult in @($azureDeactResults)) { $currentRole++ $rd = $azResult.Job.RoleData if ($azResult.Success) { Write-Verbose "Azure Resource role deactivated in parallel: $($rd.DisplayName)" $successCount++ $null = $successfullyDeactivated.Add($rd) } else { $deactivationErrors += "$($rd.DisplayName): $($azResult.ErrorMessage)" Write-Warning "Azure Resource parallel deactivation failed for $($rd.DisplayName): $($azResult.ErrorMessage)" } } } $operationSplash.UpdateStatus("Completing deactivation process...", 95) # Close splash before showing results dialog if ($operationSplash -and -not $operationSplash.IsDisposed) { $operationSplash.Close() $operationSplash = $null } # Display results - always show a message to the user $errorCount = @($deactivationErrors).Count Write-Verbose "Deactivation complete. Success: $successCount, Errors: $errorCount" if ($errorCount -gt 0) { $message = "Successfully deactivated: $successCount of $totalRoles role(s)`n`nErrors ($errorCount):`n`n$($deactivationErrors -join "`n`n")" Show-TopMostMessageBox -Message $message -Title "Deactivation Results" -Icon Warning } elseif ($successCount -gt 0) { Show-TopMostMessageBox -Message "Successfully deactivated all $successCount role(s)!" -Title "Success" -Icon Information } # Clear role cache to ensure fresh data is fetched after deactivation if ($successCount -gt 0) { Write-Verbose "Clearing role cache to force fresh data retrieval after deactivation" $script:CachedEligibleRoles = $null $script:CachedActiveRoles = $null $script:LastRoleFetchTime = $null # Register short-lived suppression entries so just-deactivated roles do not # re-appear in the active list during Graph/ARM propagation lag (typically # several seconds to a minute). Entries auto-expire so a future refresh # will show roles that truly remained active (e.g., failed deactivation # discovered out-of-band). try { if (-not (Get-Variable -Name 'RecentlyDeactivated' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:RecentlyDeactivated) { $script:RecentlyDeactivated = @{} } $suppressUntil = (Get-Date).AddMinutes(5) foreach ($rd in $successfullyDeactivated) { $key = $null try { switch ($rd.Type) { 'AzureResource' { $rdef = $rd.RoleDefinitionId if ($rdef -match '/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})') { $rdef = $matches[1] } $fs = if ($rd.PSObject.Properties['FullScope']) { $rd.FullScope } elseif ($rd.PSObject.Properties['Scope']) { $rd.Scope } else { $null } if ($fs -and $rdef) { $key = "Azure|$fs|$rdef" } } 'Entra' { $rdef = $rd.RoleDefinitionId $ds = if ($rd.PSObject.Properties['DirectoryScopeId'] -and $rd.DirectoryScopeId) { $rd.DirectoryScopeId } else { '/' } if ($rdef) { $key = "Entra|$rdef|$ds" } } 'Group' { $gid = if ($rd.PSObject.Properties['GroupId']) { $rd.GroupId } else { $null } $aid = & $getGroupAccessId $rd if ($gid -and $aid) { $key = "Group|$gid|$aid" } } } } catch {} if ($key) { $script:RecentlyDeactivated[$key] = $suppressUntil Write-Verbose "Registered active-list suppression for $key (until $suppressUntil)" # Drop any stale activation injection record for this role. if ((Get-Variable -Name 'RecentlyActivated' -Scope Script -ErrorAction SilentlyContinue) -and $script:RecentlyActivated -and $script:RecentlyActivated.ContainsKey($key)) { $null = $script:RecentlyActivated.Remove($key) } } } } catch { Write-Verbose "Failed to register deactivation suppression entries: $($_.Exception.Message)" } # Mark affected Azure subscriptions as dirty for delta refresh and clear any override expirations try { foreach ($item in $CheckedItems) { $roleData = $item.Tag if ($roleData -and $roleData.Type -eq 'AzureResource') { if (-not (Get-Variable -Name 'DirtyAzureSubscriptions' -Scope Script -ErrorAction SilentlyContinue)) { $script:DirtyAzureSubscriptions = @() } if ($roleData.PSObject.Properties['SubscriptionId'] -and $roleData.SubscriptionId) { $script:DirtyAzureSubscriptions += $roleData.SubscriptionId $script:DirtyAzureSubscriptions = @($script:DirtyAzureSubscriptions | Select-Object -Unique) Write-Verbose "Marked subscription $($roleData.SubscriptionId) as dirty after deactivation" } # If management group scope, mark MG dirty for delta refresh if ($roleData.PSObject.Properties['FullScope'] -and $roleData.FullScope -match "^/providers/Microsoft\.Management/managementGroups/([^/]+)$") { if (-not (Get-Variable -Name 'DirtyManagementGroups' -Scope Script -ErrorAction SilentlyContinue)) { $script:DirtyManagementGroups = @() } $mgName = $matches[1] $script:DirtyManagementGroups += $mgName $script:DirtyManagementGroups = @($script:DirtyManagementGroups | Select-Object -Unique) Write-Verbose "Marked management group ${mgName} as dirty after deactivation" } # Remove any Azure active override expiration for this role/scope if (Get-Variable -Name 'AzureActiveOverrides' -Scope Script -ErrorAction SilentlyContinue) { $roleDefKey = $roleData.RoleDefinitionId if ($roleDefKey -match "/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})") { $roleDefKey = $matches[1] } $overrideKey = "$($roleData.FullScope)|$($roleDefKey)" if ($script:AzureActiveOverrides.ContainsKey($overrideKey)) { $null = $script:AzureActiveOverrides.Remove($overrideKey) Write-Verbose "Cleared Azure active override for $overrideKey after deactivation" } # Also remove from AzureRolesCache if present if (Get-Variable -Name 'AzureRolesCache' -Scope Script -ErrorAction SilentlyContinue) { $script:AzureRolesCache = @($script:AzureRolesCache | Where-Object { if ($_.PSObject.Properties['RoleDefinitionId'] -and $_.PSObject.Properties['FullScope']) { $rd = $_.RoleDefinitionId; if ($rd -match "/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})") { $rd = $matches[1] } -not ($rd -eq $roleDefKey -and $_.FullScope -eq $roleData.FullScope) } else { $true } }) Write-Verbose "Pruned deactivated Azure role from AzureRolesCache for key $overrideKey" } } } } } catch { Write-Verbose "Post-deactivation delta marking failed: $($_.Exception.Message)" } } try { # Per refresh semantics: only refresh ACTIVE roles after deactivation Update-PIMRolesList -Form $Form -RefreshActive } catch { Write-Warning "Failed to refresh role lists: $_" } } finally { # Ensure splash is closed if ($operationSplash -and -not $operationSplash.IsDisposed) { $operationSplash.Close() } } Write-Verbose "Deactivation process completed - Success: $successCount, Errors: $errorCount" } |