├── Graph.EasyPIM ├── Graph.EasyPIM.psd1 └── Graph.EasyPIM.psm1 ├── LICENSE ├── README.md └── assets ├── image-20241006172734455.png ├── image-20241006172840346.png ├── image-20241006173010679.png └── image-20241006173033656.png /Graph.EasyPIM/Graph.EasyPIM.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'Graph.EasyPIM' 3 | # 4 | # Generated by: Rakhesh Sasidharan 5 | # 6 | # Generated on: 10/06/2024 7 | # 8 | 9 | @{ 10 | 11 | # Script module or binary module file associated with this manifest. 12 | RootModule = 'Graph.EasyPIM.psm1' 13 | 14 | # Version number of this module. 15 | ModuleVersion = '0.0.15' 16 | 17 | # Supported PSEditions 18 | # CompatiblePSEditions = @() 19 | 20 | # ID used to uniquely identify this module 21 | GUID = 'a4a44b22-e751-4b1c-b7d6-f6c5fca107ba' 22 | 23 | # Author of this module 24 | Author = 'Rakhesh Sasidharan' 25 | 26 | # Company or vendor of this module 27 | CompanyName = 'rakhesh.com' 28 | 29 | # Copyright statement for this module 30 | Copyright = '(c) Rakhesh Sasidharan. All rights reserved.' 31 | 32 | # Description of the functionality provided by this module 33 | Description = 'Making the end-user experience of Entra ID PIM slightly easier.' 34 | 35 | # Minimum version of the PowerShell engine required by this module 36 | # PowerShellVersion = '' 37 | 38 | # Name of the PowerShell host required by this module 39 | # PowerShellHostName = '' 40 | 41 | # Minimum version of the PowerShell host required by this module 42 | # PowerShellHostVersion = '' 43 | 44 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 45 | # DotNetFrameworkVersion = '' 46 | 47 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 48 | # ClrVersion = '' 49 | 50 | # Processor architecture (None, X86, Amd64) required by this module 51 | # ProcessorArchitecture = '' 52 | 53 | # Modules that must be imported into the global environment prior to importing this module 54 | RequiredModules = @( 55 | "Microsoft.Graph.Authentication", 56 | "Microsoft.Graph.Identity.Governance", 57 | "Microsoft.Graph.Users", 58 | "Microsoft.PowerShell.ConsoleGuiTools", 59 | "Microsoft.Graph.Identity.DirectoryManagement" 60 | ) 61 | 62 | # Assemblies that must be loaded prior to importing this module 63 | # RequiredAssemblies = @() 64 | 65 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 66 | # ScriptsToProcess = @() 67 | 68 | # Type files (.ps1xml) to be loaded when importing this module 69 | # TypesToProcess = @() 70 | 71 | # Format files (.ps1xml) to be loaded when importing this module 72 | # FormatsToProcess = @() 73 | 74 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 75 | # NestedModules = @() 76 | 77 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 78 | FunctionsToExport = @( 79 | "Enable-PIMRole", 80 | "Enable-PIMGroup", 81 | "Disable-PIMRole", 82 | "Disable-PIMGroup" 83 | ) 84 | 85 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 86 | CmdletsToExport = @() 87 | 88 | # Variables to export from this module 89 | #VariablesToExport = '*' 90 | VariablesToExport = @() 91 | 92 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 93 | AliasesToExport = @() 94 | 95 | # DSC resources to export from this module 96 | # DscResourcesToExport = @() 97 | 98 | # List of all modules packaged with this module 99 | # ModuleList = @() 100 | 101 | # List of all files packaged with this module 102 | FileList = @('Graph.EasyPIM.psm1') 103 | 104 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 105 | PrivateData = @{ 106 | 107 | PSData = @{ 108 | 109 | # Tags applied to this module. These help with module discovery in online galleries. 110 | Tags = @("Graph","PIM","EntraID") 111 | 112 | # A URL to the license for this module. 113 | LicenseUri = 'https://github.com/rakheshster/PowerShell-GraphEasyPIM/blob/main/LICENSE' 114 | 115 | # A URL to the main website for this project. 116 | ProjectUri = 'https://github.com/rakheshster/PowerShell-GraphEasyPIM' 117 | 118 | # A URL to an icon representing this module. 119 | # IconUri = '' 120 | 121 | # ReleaseNotes of this module 122 | ReleaseNotes = 'No changes. Marked Graph.Users as a depenedency. Had missed this.' 123 | 124 | # Prerelease string of this module 125 | # Prerelease = '' 126 | 127 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 128 | # RequireLicenseAcceptance = $false 129 | 130 | # External dependent modules of this module 131 | # ExternalModuleDependencies = @() 132 | 133 | } # End of PSData hashtable 134 | 135 | } # End of PrivateData hashtable 136 | 137 | # HelpInfo URI of this module 138 | # HelpInfoURI = '' 139 | 140 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 141 | # DefaultCommandPrefix = '' 142 | } -------------------------------------------------------------------------------- /Graph.EasyPIM/Graph.EasyPIM.psm1: -------------------------------------------------------------------------------- 1 | # Common variables 2 | ## The scopes that we need. Identified these from the Graph API docs + while testing. 3 | $requiredScopesArrayRoles = @("RoleEligibilitySchedule.Read.Directory","RoleEligibilitySchedule.ReadWrite.Directory", 4 | "RoleManagement.Read.Directory","RoleManagement.Read.All","RoleManagement.ReadWrite.Directory", 5 | "RoleAssignmentSchedule.ReadWrite.Directory","RoleAssignmentSchedule.Remove.Directory" 6 | ) 7 | 8 | $requiredScopesArrayGroups = @("PrivilegedEligibilitySchedule.Read.AzureADGroup","PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup", 9 | "PrivilegedAccess.Read.AzureADGroup","PrivilegedAccess.ReadWrite.AzureADGroup", 10 | "RoleManagementPolicy.Read.AzureADGroup" 11 | ) 12 | 13 | $requiredScopesArray = $requiredScopesArrayRoles + $requiredScopesArrayGroups 14 | 15 | ## The colors I will be using with Write-Host. Initially I was going to hardcode Yellow to match Write-Verbose, but then I thought what if the user has a different color scheme? 16 | ## Thanks https://www.pdq.com/blog/change-powershell-colors/ for showing me where to get these colors 17 | $colorParams = @{} 18 | if ($host.PrivateData.VerboseForegroundColor -ne "-1") { 19 | $colorParams.ForegroundColor = $host.PrivateData.VerboseForegroundColor 20 | } 21 | 22 | if ($host.PrivateData.VerboseBackgroundColor -ne "-1") { 23 | $colorParams.BackgroundColor = $host.PrivateData.VerboseBackgroundColor 24 | } 25 | 26 | ## Various variables used below to cache info 27 | $lastUpdatedGroups = @{} 28 | $lastUpdatedRoles = @{} 29 | $myEligibleRoles = @{} 30 | $myEligibleGroups = @{} 31 | $policyAssignmentHashRoles = @{} 32 | $policyObjsHashRoles = @{} 33 | $policyAssignmentHashGroupsOwner = @{} 34 | $policyAssignmentHashGroupsMember = @{} 35 | 36 | function Enable-PIMRole { 37 | param( 38 | [Parameter(Mandatory=$false)] 39 | [Alias("SkipReason")] 40 | [switch]$SkipJustification, 41 | 42 | [Parameter(Mandatory=$false)] 43 | [Alias("Reason")] 44 | [string]$Justification, 45 | 46 | [Parameter(Mandatory=$false)] 47 | [string]$TicketingSystem, 48 | 49 | [switch]$RefreshEligibleRoles, 50 | 51 | [switch]$UseDeviceCode, 52 | 53 | [Parameter(Mandatory=$false)] 54 | [string]$TenantId, 55 | 56 | [Parameter(Mandatory=$false)] 57 | [string]$ClientId 58 | ) 59 | 60 | <# 61 | .DESCRIPTION 62 | Enable Entra ID PIM roles via an easy to use TUI (Text User Interface). Only supports enabling; not disabling. Use Disable-PIMRole to disable. 63 | 64 | If a role needs a reason/ justification you can either enter one, or press enter to go with a default, or type something and end with * to use it for all the activations. 65 | 66 | .PARAMETER SkipJustification 67 | Optional. If specified, it sets the reason/ justifaction for activation to be a default. 68 | 69 | .PARAMETER Justification 70 | Optional. If specified, it sets the reason/ justifaction for activation to whatever is input. 71 | 72 | .PARAMETER TicketingSystem 73 | Optional. If specified, it sets the tickting system (for role activations that need a ticket number) to be whatever is input. 74 | 75 | .PARAMETER RefreshEligibleRoles 76 | Optional. By default, eligible roles are only checked if it's been more than 30 mins since the last invocation. If you want to check before that, use this switch. 77 | 78 | .PARAMETER UseDeviceCode 79 | Optional. Use Device Code authentication. 80 | 81 | .PARAMETER TenantId 82 | Optional. Use this TenantId. 83 | 84 | .PARAMETER ClientId 85 | Optional. Use this Client Id. 86 | #> 87 | 88 | begin { 89 | Write-Host "" 90 | $colorParams = $script:colorParams 91 | 92 | [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 93 | [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 94 | 95 | if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) { 96 | Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery" 97 | } 98 | 99 | $graphParams = @{ 100 | "Scopes" = $script:requiredScopesArray 101 | "NoWelcome" = $true 102 | "ErrorAction" = "Stop" 103 | } 104 | 105 | if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true } 106 | if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId } 107 | if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId } 108 | 109 | # Disconnect the existing sessions if one of these were provided 110 | if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 111 | try { 112 | Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null 113 | } catch {} 114 | } 115 | 116 | try { 117 | Connect-MgGraph @graphParams 118 | 119 | } catch { 120 | throw "$($_.Exception.Message)" 121 | } 122 | 123 | $context = Get-MgContext 124 | 125 | $scopes = $context.scopes 126 | 127 | if ($scopes -notcontains "Directory.ReadWrite.All") { 128 | foreach ($requiredScope in $script:requiredScopesArray) { 129 | if ($requiredScope -notin $scopes) { 130 | Write-Warning "Required scope '$requiredScope' missing" 131 | } 132 | } 133 | } 134 | 135 | $userId = (Get-MgUser -UserId $context.Account).Id 136 | 137 | if ($RefreshEligibleRoles) { 138 | $needsUpdating = $true 139 | 140 | } else { 141 | # Only pull in the eligible roles if needed; else use the cached info 142 | $currentTime = (Get-Date).ToUniversalTime() 143 | $lastUpdatedRoles = $script:lastUpdatedRoles[$userId] 144 | 145 | if ($null -ne $lastUpdatedRoles) { 146 | $lastUpdatedTimespan = New-TimeSpan -Start $lastUpdatedRoles -End $currentTime 147 | 148 | if ($lastUpdatedTimespan.TotalMinutes -gt 30) { 149 | $needsUpdating = $true 150 | 151 | } else { 152 | $needsUpdating = $false 153 | if ($lastUpdatedTimespan.TotalMinutes -eq 1) { 154 | $minutes = "a minute" 155 | 156 | } else { 157 | $minutes = "$([int]$lastUpdatedTimespan.TotalMinutes) minutes" 158 | } 159 | } 160 | 161 | } else { 162 | $needsUpdating = $true 163 | } 164 | } 165 | 166 | try { 167 | if ($needsUpdating) { 168 | Write-Host @colorParams "🥷 Fetching all eligible & active Entra ID roles. This could take a few minutes." 169 | 170 | Write-Progress -Activity "Fetching all eligible Entra ID roles" -Id 0 171 | [array]$myEligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop 172 | [array]$script:myEligibleRoles[$userId] = $myEligibleRoles 173 | 174 | } else { 175 | Write-Host @colorParams "⏳ Not fetching eligible Entra ID roles & their settings as it has only been $minutes since we last checked." 176 | Write-Host @colorParams "🫵 You can re-run with the -RefreshEligibleRoles switch to force a refresh." 177 | [array]$myEligibleRoles = $script:myEligibleRoles[$userId] 178 | } 179 | 180 | Write-Progress -Activity "Fetching all active Entra ID roles" -Id 0 181 | [array]$myActiveRoles = Get-MgRoleManagementDirectoryRoleAssignmentSchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop 182 | 183 | } catch { 184 | throw "Error fetching roles: $($_.Exception.Message)" 185 | } 186 | 187 | Write-Progress -Id 0 -Completed 188 | 189 | # Create a cache of assignments. This is faster as I can lookup a bunch of them beforehand. 190 | # All roles have the same policy (settings) assigned to them. And a user could have the same role assigned in more than one way - e.g. various admin units. 191 | $policyAssignmentHashRoles = @{} 192 | # I must set scopeId to '/' coz if I search for a specific scopeId it errors: Attempted to perform an unauthorized operation. 193 | $searchSnippetMain = "scopeType eq 'DirectoryRole' and scopeId eq '/' and (" 194 | $searchSnippetsArray = @() 195 | 196 | # Filter has a max length (not sure what) so I will do it in batches of 5. 197 | # A temp variable I keep incrementing 198 | $counter = 0 199 | # Total number of entries for this scope 200 | $totalCount = $myEligibleRoles.Count 201 | 202 | # Loop through the entries 203 | if ($needsUpdating) { 204 | Write-Host @colorParams "🚀 Fetching all role assignment settings. This could take a few minutes." 205 | 206 | foreach ($roleObj in $myEligibleRoles) { 207 | $counter++ 208 | $roleDefinitionId = $roleObj.RoleDefinitionId 209 | 210 | # An array where I keep adding the snippets 211 | $searchSnippetsArray += "roleDefinitionId eq '$roleDefinitionId'" 212 | 213 | Write-Progress -Activity "Fetching..." -Id 0 -Status "${counter}/${totalCount}" -PercentComplete $($counter*100/$totalCount) 214 | 215 | # In batches of 5, or if the counter has reached the end... 216 | if ($counter % 5 -eq 0 -or $counter -ge $totalCount) { 217 | # ... construct the search snippet 218 | $searchSnippet = $searchSnippetMain + $($searchSnippetsArray -join ' or ') + ")" 219 | 220 | # Do the search 221 | try { 222 | $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter $searchSnippet -ExpandProperty "policy(`$expand=rules)" -ErrorAction Stop 223 | 224 | } catch { 225 | throw "Error fetching settings assignments: $($_.Exception.Message)" 226 | } 227 | 228 | # And add it to the hash 229 | foreach ($result in $policyAssignment) { 230 | $policyAssignmentHashRoles[$($result.RoleDefinitionId)] = $result 231 | } 232 | 233 | # Initialize the array again 234 | $searchSnippetsArray = @() 235 | } 236 | } 237 | 238 | Write-Progress -Id 0 -Completed 239 | 240 | # Fetching all the policies 241 | Write-Host @colorParams "🧙 Fetching all role settings." 242 | 243 | try { 244 | $policyObjsHashRoles = @{} 245 | 246 | Get-MgPolicyRoleManagementPolicy -All -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole'" -ExpandProperty Rules -ErrorAction Stop | ForEach-Object { 247 | $policyObjsHashRoles[$($_.Id)] = $_ 248 | } 249 | 250 | } catch { 251 | throw "Error fetching all the settings: $($_.Exception.Message)" 252 | } 253 | 254 | $script:policyAssignmentHashRoles[$userId] = $policyAssignmentHashRoles 255 | $script:policyObjsHashRoles[$userId] = $policyObjsHashRoles 256 | 257 | $script:lastUpdatedRoles[$userId] = $currentTime # Set the lastUpdated timestamp since we have successfully updated the cache 258 | 259 | } else { 260 | $policyAssignmentHashRoles = $script:policyAssignmentHashRoles[$userId] 261 | $policyObjsHashRoles = $script:policyObjsHashRoles[$userId] 262 | 263 | } 264 | } 265 | 266 | process { 267 | Write-Host "" 268 | $policyEnablementRulesCache = @{} 269 | $roleDefinitionsCache = @{} 270 | 271 | # Random 12 lower case characters 272 | # $defaultJustification = -join ((97..122) | Get-Random -Count 12 | ForEach-Object {[char]$_}) 273 | $defaultJustification = "Activated using Graph.EasyPIM" 274 | 275 | if ($env:USERDOMAIN) { 276 | $userDomain = "$($env:USERDOMAIN)\" 277 | } 278 | 279 | if ($env:USER) { 280 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USER)" 281 | } elseif ($env:LOGNAME) { 282 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:LOGNAME)" 283 | } elseif ($env:USERNAME) { 284 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USERNAME)" 285 | } 286 | 287 | if ($env:ComputerName) { 288 | $defaultJustification = $defaultJustification + " on $($env:ComputerName)" 289 | } 290 | 291 | # I use these for showing progress 292 | [int]$counter = 0 293 | [int]$totalCount = $myEligibleRoles.Count 294 | 295 | $roleStates = foreach ($roleObj in $myEligibleRoles) { 296 | $counter++ 297 | $percentageComplete = ($counter/$totalCount)*100 298 | 299 | $roleDefinitionId = $roleObj.RoleDefinitionId 300 | $roleName = $roleObj.RoleDefinition.DisplayName 301 | $roleDirectoryScopeId = $roleObj.DirectoryScopeId 302 | 303 | $roleDefinitionsCache[$roleDefinitionId] = $roleName 304 | 305 | $timespanArray = @() 306 | $roleExpired = $false 307 | $roleAssignmentType = "Inactive" 308 | 309 | Write-Progress -Activity "Processing role '$roleName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount" 310 | 311 | $activeRoleObj = $null 312 | $activeRoleObj = $myActiveRoles | Where-Object { $_.RoleDefinitionId -eq "$roleDefinitionId" -and $_.DirectoryScopeId -eq "$roleDirectoryScopeId" } 313 | 314 | if ($activeRoleObj) { 315 | Write-Progress -Activity "Role is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 316 | Start-Sleep -Milliseconds 200 # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741 317 | Write-Progress -Activity "Role is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 318 | 319 | # Double checking coz during my testing I ran into instances where this was sometimes incomplete 320 | if ($activeRoleObj.ScheduleInfo.Expiration.EndDateTime) { 321 | # $roleAssignmentType = $activeRoleObj.AssignmentType 322 | $roleAssignmentType = "Active" 323 | 324 | $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeRoleObj.ScheduleInfo.Expiration.EndDateTime 325 | if ($timeSpan.Days -gt 0) { 326 | if ($timeSpan.Days -eq 1) { 327 | $timespanArray += "$($timeSpan.Days) day" 328 | 329 | } else { 330 | $timespanArray += "$($timeSpan.Days) days" 331 | } 332 | } 333 | 334 | if ($timeSpan.Hours -gt 0) { 335 | if ($timeSpan.Hours -eq 1) { 336 | $timespanArray += "$($timeSpan.Hours) hour" 337 | 338 | } else { 339 | $timespanArray += "$($timeSpan.Hours) hours" 340 | } 341 | } 342 | 343 | if ($timeSpan.Minutes -gt 0) { 344 | if ($timeSpan.Minutes -eq 1) { 345 | $timespanArray += "$($timeSpan.Minutes) minute" 346 | 347 | } else { 348 | $timespanArray += "$($timeSpan.Minutes) minutes" 349 | } 350 | } 351 | 352 | # Just in case there's a delay between getting the states and when I calculate this... 353 | if ($timeSpan.Ticks -lt 0) { 354 | $roleExpired = $true 355 | } 356 | 357 | } else { 358 | $roleExpired = $true 359 | } 360 | 361 | Write-Progress -Id 1 -Completed 362 | 363 | } else { 364 | $roleExpired = $true 365 | } 366 | 367 | # Using the roledefinitionid, find the policy assignment on this role 368 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyassignment?view=graph-rest-1.0 369 | 370 | <# 371 | $roleDirectoryScopeId = $roleObj.DirectoryScopeId 372 | 373 | Write-Progress -Activity "Fetching policy assignment of role '$roleName'" -Id 2 -PercentComplete $percentageComplete -Status "$counter/$totalCount" 374 | try { 375 | $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter "scopeId eq '$roleDirectoryScopeId' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$roleDefinitionId'" -ErrorAction Stop 376 | 377 | } catch { 378 | Write-Warning "Error fetching policy assignments for '$roleName': $($_.Exception.Message)" 379 | continue 380 | } 381 | #> 382 | # Skipping the above code as I now cache it before hand. This is faster than doing individual lookups. 383 | $policyAssignment = $policyAssignmentHashRoles[$roleDefinitionId] 384 | 385 | # From there find the policy :) 386 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicy?view=graph-rest-1.0 387 | $policyId = $policyAssignment.PolicyId 388 | 389 | # Look it up in the cached table; but in the off chance that it isn't there, look it up directly 390 | if ($policyObjsHashRoles.Keys -contains $policyId) { 391 | $policyObj = $policyObjsHashRoles[$policyId] 392 | 393 | } else { 394 | Write-Progress -Activity "Fetching settings '$(($policyId -split '_')[2])'" -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 395 | Start-Sleep -Milliseconds 200 # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741 396 | Write-Progress -Activity "Fetching settings '$(($policyId -split '_')[2])'" -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 397 | 398 | try { 399 | $policyObj = Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId $policyId -ExpandProperty Rules -ErrorAction Stop 400 | 401 | $policyObjsHashRoles[$policyId] = $policyObj # caching it for within this current execution 402 | $script:policyObjsHashRoles[$userId][$policyId] = $policyObj # caching it for future invocations of the module 403 | 404 | } catch { 405 | Write-Warning "Error fetching settings id '$policyId': $($_.Exception.Message)" 406 | continue 407 | } 408 | } 409 | 410 | # The policy is what defines the max duration of the role and other factors. We are interested in here are the rules 411 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyrule?view=graph-rest-1.0 412 | 413 | # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration 414 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0 415 | $expirationRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties 416 | 417 | if ($expirationRule.maximumDuration -match "^PT") { 418 | # Thanks https://stackoverflow.com/a/57296616 419 | $timeSpan = [System.Xml.XmlConvert]::ToTimeSpan($expirationRule.maximumDuration) 420 | 421 | $maxDurationArray = @() 422 | 423 | if ($timeSpan.Days -gt 0) { 424 | if ($timeSpan.Days -eq 1) { 425 | $maxDurationArray += "$($timeSpan.Days) day" 426 | 427 | } else { 428 | $maxDurationArray += "$($timeSpan.Days) days" 429 | } 430 | } 431 | 432 | if ($timeSpan.Hours -gt 0) { 433 | if ($timeSpan.Hours -eq 1) { 434 | $maxDurationArray += "$($timeSpan.Hours) hour" 435 | 436 | } else { 437 | $maxDurationArray += "$($timeSpan.Hours) hours" 438 | } 439 | } 440 | 441 | if ($timeSpan.Minutes -gt 0) { 442 | if ($timeSpan.Minutes -eq 1) { 443 | $maxDurationArray += "$($timeSpan.Minutes) minute" 444 | 445 | } else { 446 | $maxDurationArray += "$($timeSpan.Minutes) minutes" 447 | } 448 | } 449 | 450 | $maxDuration = $maxDurationArray -join ' ' 451 | 452 | } else { 453 | $maxDuration = $expirationRule.maximumDuration 454 | } 455 | 456 | # Repeat, but for the enablement rules 457 | if ($policyEnablementRulesCache.Keys -contains $policyId) { 458 | $enablementRule = $policyEnablementRulesCache.$policyId 459 | 460 | } else { 461 | # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration 462 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0 463 | $enablementRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Enablement_EndUser_Assignment" }).AdditionalProperties.enabledRules 464 | $policyEnablementRulesCache.$policyId = $enablementRule 465 | } 466 | 467 | # Thanks to https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/assign-roles-different-scopes 468 | if ($roleDirectoryScopeId -eq '/') { 469 | $roleScope = "Tenant" 470 | 471 | } elseif ($roleDirectoryScopeId -match "\/administrativeUnits\/") { 472 | $adminUnitId = $roleDirectoryScopeId -replace '\/administrativeUnits\/','' 473 | try { 474 | $adminUnitName = (Get-MgDirectoryAdministrativeUnit -AdministrativeUnitId $adminUnitId -ErrorAction Stop).DisplayName 475 | 476 | } catch { 477 | $adminUnitName = $adminUnitId 478 | } 479 | 480 | $roleScope = "$adminUnitName (Admin Unit)" 481 | 482 | } else { 483 | $appScope = $roleDirectoryScopeId -replace '\/','' 484 | $roleScope = "$appScope (App)" 485 | } 486 | 487 | Write-Progress -Completed -Id 1 488 | 489 | [pscustomobject][ordered]@{ 490 | "RoleName" = $roleName 491 | "Status" = $roleAssignmentType 492 | "ExpiresIn" = if (!($roleExpired)) { 493 | # Take only the topmost entry (day or hour in case of more than one) 494 | if ($timespanArray.Count -gt 1) { 495 | "~" + $timespanArray[0] 496 | } else { 497 | $timespanArray[0] 498 | } 499 | } # Tweak the output to to save some space 500 | 501 | "MaxDuration" = $maxDuration 502 | "EnablementRules" = $enablementRule -join '|' -replace 'Justification','Reason' -replace 'Ticketing','Ticket' -replace 'MultiFactorAuthentication','MFA' 503 | "Scope" = $roleScope 504 | "More" = [pscustomobject]@{ 505 | "More" = [pscustomobject]@{ 506 | "RoleDefinitionId" = $roleObj.RoleDefinitionId 507 | "DirectoryScopeId" = $roleDirectoryScopeId 508 | "MaxDuration" = $expirationRule.maximumDuration 509 | "EnablementRule" = $enablementRule 510 | "ActiveMinutes" = if (!($roleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeRoleObj.ScheduleInfo.StartDateTime).TotalMinutes } 511 | } 512 | } # Two levels to hide this and save some space 513 | } 514 | } 515 | 516 | Write-Progress -Completed -Id 0 517 | 518 | $userSelections = $roleStates | Out-ConsoleGridView -Title "List of active & eligible Entra ID PIM roles (count: $totalCount)" 519 | 520 | # Let's ask for the required info upfront 521 | $justificationsHash = @{} 522 | $ticketSystemHash = @{} 523 | $ticketNumberHash = @{} 524 | 525 | # I use this for tidying up some of the output later; find the longest entry in the selections 526 | $longestRoleLength = ($userSelections.RoleName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 527 | $longestScopeLength = ($userSelections.Scope | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 528 | 529 | $rolesWereDisabled = $false 530 | foreach ($selection in $userSelections) { 531 | if ($selection.Status -ne "Inactive") { 532 | if ($selection.More.More.ActiveMinutes -le 5) { 533 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 534 | Write-Host "Cannot disable the role as it must be active for at least 5 minutes." 535 | continue 536 | } 537 | 538 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 539 | Write-Host "Disabling role (so we can enable it again)" 540 | 541 | $params = @{ 542 | Action = "selfDeactivate" 543 | PrincipalId = $userId 544 | RoleDefinitionId = $selection.More.More.RoleDefinitionId 545 | DirectoryScopeId = $selection.More.More.DirectoryScopeId 546 | } 547 | 548 | try { 549 | $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 550 | 551 | $rolesWereDisabled = $true 552 | 553 | } catch { 554 | Write-Error "Error deactivating '$($selection.RoleName)': $($_.Exception.Message)" 555 | } 556 | } 557 | } 558 | 559 | if ($rolesWereDisabled) { 560 | $counter = 0 561 | $maxWaitSecs = 20 562 | while ($counter -lt $maxWaitSecs) { 563 | Write-Progress "Waiting $maxWaitSecs seconds before continuing" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 564 | Start-Sleep -Seconds 1 565 | $counter++ 566 | } 567 | 568 | Write-Progress -Completed 569 | Write-Host "" 570 | } 571 | 572 | foreach ($selection in $userSelections) { 573 | # Skip activating active roles that have been active for less than 5 mins 574 | # Coz we wouldn't have been able to disable them above to reactivate 575 | if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue } 576 | 577 | if ($selection.More.More.EnablementRule -contains "Justification") { 578 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 579 | 580 | if ($SkipJustification) { 581 | $justificationsHash[$($selection.RoleName)] = "$defaultJustification" 582 | Write-Host "Reason will be set to: $defaultJustification" 583 | 584 | } elseif ($Justification.Length -ne 0) { 585 | $justificationsHash[$($selection.RoleName)] = $Justification 586 | Write-Host "Reason will be set to: $Justification" 587 | 588 | } else { 589 | $justificationInput = Read-Host "Please provide a reason" 590 | 591 | # If the justitication ends with an asterisk or is empty, use it for everything else that follows... 592 | if ($justificationInput -match '\*$' -or $justificationInput.Length -eq 0) { 593 | # First, remove the asterisk 594 | $justificationInput = $justificationInput -replace '\*$','' 595 | 596 | # Then check whether anything remains. This is to cater to situations where someone enters * or *** etc. 597 | # If after removing the asterisk there's nothing, then set it to $defaultJustification for all. This is basically equivalent to -SkipJustification 598 | if ($justificationInput.Length -eq 0) { 599 | $justificationInput = "$defaultJustification" 600 | $justificationsHash[$($selection.RoleName)] = $justificationInput 601 | } 602 | 603 | # Set the justification for everything that follows to be this 604 | $Justification = $justificationInput 605 | $justificationsHash[$($selection.RoleName)] = $justificationInput 606 | 607 | } else { 608 | $justificationsHash[$($selection.RoleName)] = $justificationInput 609 | } 610 | 611 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 612 | Write-Host "Reason will be set to: $justificationInput" 613 | } 614 | } 615 | 616 | if ($selection.More.More.EnablementRule -contains "Ticketing") { 617 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 618 | 619 | $ticketNumberHash[$($selection.RoleName)] = Read-Host "Please provide a ticket number" 620 | 621 | if ($TicketingSystem.Length -ne 0) { 622 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 623 | $ticketingSystemInput = Read-Host "Please provide the ticketing system name" 624 | 625 | # If the justitication ends with an asterisk, use it for everything else that follows... 626 | if ($ticketingSystemInput -match '\*$') { 627 | $ticketingSystemInput = $ticketingSystemInput -replace '\*$','' 628 | $TicketingSystem = $ticketingSystemInput 629 | } 630 | 631 | $ticketSystemHash[$($selection.RoleName)] = $ticketingSystemInput 632 | 633 | } else { 634 | $ticketSystemHash[$($selection.RoleName)] = $TicketingSystem 635 | } 636 | } 637 | } 638 | 639 | if ($userSelections.Count -ne 0) { 640 | Write-Host "" 641 | } 642 | 643 | # An array to capture each of the items we action below 644 | $requestObjsArray = @() 645 | 646 | foreach ($selection in $userSelections) { 647 | # Skip activating active roles that have been active for less than 5 mins 648 | # Coz we wouldn't have been able to disable them above to reactivate 649 | if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue } 650 | 651 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 652 | Write-Host "Enabling for $($selection.MaxDuration)" 653 | 654 | $params = @{ 655 | Action = "selfActivate" 656 | PrincipalId = $userId 657 | RoleDefinitionId = $selection.More.More.RoleDefinitionId 658 | DirectoryScopeId = $selection.More.More.DirectoryScopeId 659 | 660 | ScheduleInfo = @{ 661 | StartDateTime = Get-Date 662 | Expiration = @{ 663 | Type = "AfterDuration" 664 | Duration = $selection.More.More.MaxDuration 665 | } 666 | } 667 | } 668 | 669 | if ($selection.More.More.EnablementRule -contains "Justification") { 670 | $params.Justification = $justificationsHash[$($selection.RoleName)] 671 | } 672 | 673 | if ($selection.More.More.EnablementRule -contains "Ticketing") { 674 | $params.TicketInfo = @{ 675 | TicketNumber = $ticketNumberHash[$($selection.RoleName)] 676 | TicketSystem = $ticketSystemHash[$($selection.RoleName)] 677 | } 678 | } 679 | 680 | try { 681 | $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 682 | 683 | # Show the output to screen 684 | 685 | <# 686 | $requestObj | Select-Object -Property @{ 687 | "Name" = "Role"; 688 | "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] } 689 | },Status 690 | #> 691 | 692 | # And add it to an array so we can loop over in the end 693 | $requestObjsArray += $requestObj 694 | 695 | } catch { 696 | Write-Error "Error activating '$($selection.RoleName)': $($_.Exception.Message)" 697 | } 698 | } 699 | 700 | if ($requestObjsArray.Count -ne 0) { 701 | Write-Host "" 702 | 703 | $counter = 0 704 | $maxWaitSecs = 20 705 | while ($counter -lt $maxWaitSecs) { 706 | Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 707 | Start-Sleep -Seconds 1 708 | $counter++ 709 | } 710 | 711 | Write-Progress -Completed 712 | } 713 | 714 | $counter = 0 715 | $totalCount = $requestObjsArray.Count 716 | 717 | $finalOutput = foreach ($requestObj in $requestObjsArray) { 718 | $counter++ 719 | Write-Progress "Fetching status of role '$($roleDefinitionsCache[$($requestObj.RoleDefinitionId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 720 | 721 | Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -UnifiedRoleAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{ 722 | "Name" = "Role"; 723 | "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] } 724 | },Status 725 | } 726 | 727 | $finalOutput | Format-Table 728 | } 729 | } 730 | 731 | # This is a copy paste of Enable-PIMRole with some bits removed... 732 | # It's very simple compared to Enable-PIMRole 733 | function Disable-PIMRole { 734 | param( 735 | [switch]$UseDeviceCode, 736 | 737 | [Parameter(Mandatory=$false)] 738 | [string]$TenantId, 739 | 740 | [Parameter(Mandatory=$false)] 741 | [string]$ClientId 742 | ) 743 | 744 | <# 745 | .PARAMETER UseDeviceCode 746 | Optional. Use Device Code authentication. 747 | 748 | .PARAMETER TenantId 749 | Optional. Use this TenantId. 750 | 751 | .PARAMETER ClientId 752 | Optional. Use this Client Id. 753 | #> 754 | 755 | begin { 756 | Write-Host "" 757 | $colorParams = $script:colorParams 758 | 759 | [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 760 | [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 761 | 762 | if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) { 763 | Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery" 764 | } 765 | 766 | $graphParams = @{ 767 | "Scopes" = $script:requiredScopesArray 768 | "NoWelcome" = $true 769 | "ErrorAction" = "Stop" 770 | } 771 | 772 | if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true } 773 | if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId } 774 | if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId } 775 | 776 | # Disconnect the existing sessions if one of these were provided 777 | if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 778 | try { 779 | Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null 780 | } catch {} 781 | } 782 | 783 | try { 784 | Connect-MgGraph @graphParams 785 | 786 | } catch { 787 | throw "$($_.Exception.Message)" 788 | } 789 | 790 | $context = Get-MgContext 791 | 792 | $scopes = $context.scopes 793 | 794 | if ($scopes -notcontains "Directory.ReadWrite.All") { 795 | foreach ($requiredScope in $script:requiredScopesArray) { 796 | if ($requiredScope -notin $scopes) { 797 | Write-Warning "Required scope '$requiredScope' missing" 798 | } 799 | } 800 | } 801 | 802 | $userId = (Get-MgUser -UserId $context.Account).Id 803 | 804 | try { 805 | Write-Host @colorParams "🥷 Fetching all active Entra ID roles. This is usually pretty quick!" 806 | 807 | Write-Progress -Activity "Fetching all active Entra ID roles" -Id 0 808 | [array]$myActiveRoles = Get-MgRoleManagementDirectoryRoleAssignmentSchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop 809 | 810 | } catch { 811 | throw "Error fetching roles: $($_.Exception.Message)" 812 | } 813 | 814 | Write-Progress -Id 0 -Completed 815 | } 816 | 817 | process { 818 | Write-Host "" 819 | 820 | $roleDefinitionsCache = @{} 821 | 822 | # I use these for showing progress 823 | [int]$counter = 0 824 | [int]$totalCount = $myActiveRoles.Count 825 | 826 | $roleStates = foreach ($roleObj in $myActiveRoles) { 827 | $counter++ 828 | $percentageComplete = ($counter/$totalCount)*100 829 | 830 | $roleDefinitionId = $roleObj.RoleDefinitionId 831 | $roleName = $roleObj.RoleDefinition.DisplayName 832 | $roleDirectoryScopeId = $roleObj.DirectoryScopeId 833 | 834 | $roleDefinitionsCache[$roleDefinitionId] = $roleName 835 | 836 | $timespanArray = @() 837 | $roleExpired = $false 838 | $roleAssignmentType = "Inactive" 839 | 840 | Write-Progress -Activity "Processing role '$roleName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount" 841 | 842 | Write-Progress -Activity "Calculating role durations" -ParentId 0 -Id 1 -Status "Waiting..." 843 | Start-Sleep -Milliseconds 200 # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741 844 | Write-Progress -Activity "Calculating role durations" -ParentId 0 -Id 1 -Status "Waiting..." 845 | 846 | $activeRoleObj = $myActiveRoles | Where-Object { $_.RoleDefinitionId -eq "$roleDefinitionId" } 847 | 848 | # Double checking coz during my testing I ran into instances where this was sometimes incomplete 849 | if ($activeRoleObj.ScheduleInfo.Expiration.EndDateTime) { 850 | # $roleAssignmentType = $activeRoleObj.AssignmentType 851 | $roleAssignmentType = "Active" 852 | 853 | $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeRoleObj.ScheduleInfo.Expiration.EndDateTime 854 | if ($timeSpan.Days -gt 0) { 855 | if ($timeSpan.Days -eq 1) { 856 | $timespanArray += "$($timeSpan.Days) day" 857 | 858 | } else { 859 | $timespanArray += "$($timeSpan.Days) days" 860 | } 861 | } 862 | 863 | if ($timeSpan.Hours -gt 0) { 864 | if ($timeSpan.Hours -eq 1) { 865 | $timespanArray += "$($timeSpan.Hours) hour" 866 | 867 | } else { 868 | $timespanArray += "$($timeSpan.Hours) hours" 869 | } 870 | } 871 | 872 | if ($timeSpan.Minutes -gt 0) { 873 | if ($timeSpan.Minutes -eq 1) { 874 | $timespanArray += "$($timeSpan.Minutes) minute" 875 | 876 | } else { 877 | $timespanArray += "$($timeSpan.Minutes) minutes" 878 | } 879 | } 880 | 881 | # Just in case there's a delay between getting the states and when I calculate this... 882 | if ($timeSpan.Ticks -lt 0) { 883 | $roleExpired = $true 884 | } 885 | 886 | } else { 887 | $roleExpired = $true 888 | } 889 | 890 | # Thanks to https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/assign-roles-different-scopes 891 | if ($roleDirectoryScopeId -eq '/') { 892 | $roleScope = "Tenant" 893 | 894 | } elseif ($roleDirectoryScopeId -match "\/administrativeUnits\/") { 895 | $adminUnitId = $roleDirectoryScopeId -replace '\/administrativeUnits\/','' 896 | try { 897 | $adminUnitName = (Get-MgDirectoryAdministrativeUnit -AdministrativeUnitId $adminUnitId -ErrorAction Stop).DisplayName 898 | 899 | } catch { 900 | $adminUnitName = $adminUnitId 901 | } 902 | 903 | $roleScope = "$adminUnitName (Admin Unit)" 904 | 905 | } else { 906 | $appScope = $roleDirectoryScopeId -replace '\/','' 907 | $roleScope = "$appScope (App)" 908 | } 909 | 910 | Write-Progress -Id 1 -Completed 911 | 912 | [pscustomobject][ordered]@{ 913 | "RoleName" = $roleName 914 | "Status" = $roleAssignmentType 915 | "ExpiresIn" = if (!($roleExpired)) { 916 | # Take only the topmost entry (day or hour in case of more than one) 917 | if ($timespanArray.Count -gt 1) { 918 | "~" + $timespanArray[0] 919 | } else { 920 | $timespanArray[0] 921 | } 922 | } # Tweak the output to to save some space 923 | 924 | "Scope" = $roleScope 925 | "More" = [pscustomobject]@{ 926 | "More" = [pscustomobject]@{ 927 | "RoleDefinitionId" = $roleObj.RoleDefinitionId 928 | "DirectoryScopeId" = $roleObj.DirectoryScopeId 929 | "ActiveMinutes" = (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeRoleObj.ScheduleInfo.StartDateTime).TotalMinutes 930 | } 931 | } # Two levels to hide this and save some space 932 | } 933 | } 934 | 935 | Write-Progress -Id 0 -Completed 936 | 937 | if ($roleStates.Count -eq 0) { 938 | Write-Host @colorParams ("🚀 No active Entra ID roles found.") 939 | Write-Host "" 940 | } 941 | 942 | $userSelections = $roleStates | Out-ConsoleGridView -Title "List of active Entra ID PIM roles" 943 | 944 | # I use this for tidying up some of the output later; find the longest entry in the selections 945 | $longestRoleLength = ($userSelections.RoleName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 946 | $longestScopeLength = ($userSelections.Scope | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 947 | 948 | # An array to capture each of the items we action below 949 | $requestObjsArray = @() 950 | 951 | foreach ($selection in $userSelections) { 952 | if ($selection.More.More.ActiveMinutes -le 5) { 953 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 954 | Write-Host "Cannot disable the role as it must be active for at least 5 minutes." 955 | continue 956 | } 957 | 958 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope)) 959 | Write-Host "Disabling role" 960 | 961 | $params = @{ 962 | Action = "selfDeactivate" 963 | PrincipalId = $userId 964 | RoleDefinitionId = $selection.More.More.RoleDefinitionId 965 | DirectoryScopeId = $selection.More.More.DirectoryScopeId 966 | } 967 | 968 | try { 969 | $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 970 | 971 | # And add it to an array so we can loop over in the end 972 | $requestObjsArray += $requestObj 973 | 974 | } catch { 975 | Write-Error "Error deactivating '$($selection.RoleName)': $($_.Exception.Message)" 976 | } 977 | } 978 | 979 | if ($requestObjsArray.Count -ne 0) { 980 | Write-Host "" 981 | 982 | $counter = 0 983 | $maxWaitSecs = 20 984 | while ($counter -lt $maxWaitSecs) { 985 | Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 986 | Start-Sleep -Seconds 1 987 | $counter++ 988 | } 989 | 990 | Write-Progress -Completed 991 | } 992 | 993 | $counter = 0 994 | $totalCount = $requestObjsArray.Count 995 | 996 | $finalOutput = foreach ($requestObj in $requestObjsArray) { 997 | $counter++ 998 | Write-Progress "Fetching status of role '$($roleDefinitionsCache[$($requestObj.RoleDefinitionId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 999 | 1000 | Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -UnifiedRoleAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{ 1001 | "Name" = "Role"; 1002 | "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] } 1003 | },Status 1004 | } 1005 | 1006 | $finalOutput | Format-Table 1007 | } 1008 | } 1009 | 1010 | function Enable-PIMGroup { 1011 | param( 1012 | [Parameter(Mandatory=$false)] 1013 | [Alias("SkipReason")] 1014 | [switch]$SkipJustification, 1015 | 1016 | [Parameter(Mandatory=$false)] 1017 | [Alias("Reason")] 1018 | [string]$Justification, 1019 | 1020 | [Parameter(Mandatory=$false)] 1021 | [string]$TicketingSystem, 1022 | 1023 | [switch]$RefreshEligibleGroups, 1024 | 1025 | [switch]$UseDeviceCode, 1026 | 1027 | [Parameter(Mandatory=$false)] 1028 | [string]$TenantId, 1029 | 1030 | [Parameter(Mandatory=$false)] 1031 | [string]$ClientId 1032 | ) 1033 | 1034 | <# 1035 | .DESCRIPTION 1036 | Enable Entra ID PIM groups via an easy to use TUI (Text User Interface). Only supports enabling; not disabling. Use Disable-PIMGroup to disable. 1037 | 1038 | If a group needs a reason/ justification you can either enter one, or press enter to go with a default, or type something and end with * to use it for all the activations. 1039 | 1040 | .PARAMETER SkipJustification 1041 | Optional. If specified, it sets the reason/ justifaction for activation to be a default. 1042 | 1043 | .PARAMETER Justification 1044 | Optional. If specified, it sets the reason/ justifaction for activation to whatever is input. 1045 | 1046 | .PARAMETER TicketingSystem 1047 | Optional. If specified, it sets the tickting system (for group activations that need a ticket number) to be whatever is input. 1048 | 1049 | .PARAMETER RefreshEligibleGroups 1050 | Optional. By default, eligible groups are only checked if it's been more than 30 mins since the last invocation. If you want to check before that, use this switch. 1051 | #> 1052 | 1053 | begin { 1054 | Write-Host "" 1055 | $colorParams = $script:colorParams 1056 | 1057 | [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 1058 | [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 1059 | 1060 | if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) { 1061 | Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery" 1062 | } 1063 | 1064 | $graphParams = @{ 1065 | "Scopes" = $script:requiredScopesArray 1066 | "NoWelcome" = $true 1067 | "ErrorAction" = "Stop" 1068 | } 1069 | 1070 | if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true } 1071 | if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId } 1072 | if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId } 1073 | 1074 | # Disconnect the existing sessions if one of these were provided 1075 | if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 1076 | try { 1077 | Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null 1078 | } catch {} 1079 | } 1080 | 1081 | try { 1082 | Connect-MgGraph @graphParams 1083 | 1084 | } catch { 1085 | throw "$($_.Exception.Message)" 1086 | } 1087 | 1088 | $context = Get-MgContext 1089 | 1090 | $scopes = $context.scopes 1091 | 1092 | if ($scopes -notcontains "Directory.ReadWrite.All") { 1093 | foreach ($requiredScope in $script:requiredScopesArray) { 1094 | if ($requiredScope -notin $scopes) { 1095 | Write-Warning "Required scope '$requiredScope' missing" 1096 | } 1097 | } 1098 | } 1099 | 1100 | $userId = (Get-MgUser -UserId $context.Account).Id 1101 | 1102 | if ($RefreshEligibleGroups) { 1103 | $needsUpdating = $true 1104 | 1105 | } else { 1106 | # Only pull in the eligible Groups if needed; else use the cached info 1107 | $currentTime = (Get-Date).ToUniversalTime() 1108 | $lastUpdatedGroups = $script:lastUpdatedGroups[$userId] 1109 | 1110 | if ($null -ne $lastUpdatedGroups) { 1111 | $lastUpdatedTimespan = New-TimeSpan -Start $lastUpdatedGroups -End $currentTime 1112 | 1113 | if ($lastUpdatedTimespan.TotalHours -gt 8) { 1114 | $needsUpdating = $true 1115 | 1116 | } else { 1117 | $needsUpdating = $false 1118 | if ($lastUpdatedTimespan.TotalHours -eq 1) { 1119 | $minutes = "an hour" 1120 | 1121 | } elseif ($lastUpdatedTimespan.TotalHours -eq 0) { 1122 | if ($lastUpdatedTimespan.TotalMinutes -eq 1) { 1123 | $minutes = "a minute" 1124 | 1125 | } else { 1126 | $minutes = "$([int]$lastUpdatedTimespan.TotalMinutes) minutes" 1127 | } 1128 | } 1129 | else { 1130 | $minutes = "$([int]$lastUpdatedTimespan.TotalHours) hours" 1131 | } 1132 | } 1133 | 1134 | } else { 1135 | $needsUpdating = $true 1136 | } 1137 | } 1138 | 1139 | try { 1140 | if ($needsUpdating) { 1141 | Write-Host @colorParams "🥷 Fetching all eligible & active Entra ID groups. This might take a few minutes." 1142 | 1143 | Write-Progress -Activity "Fetching all eligible Entra ID groups" -Id 0 1144 | [array]$myEligibleGroups = Get-MgIdentityGovernancePrivilegedAccessGroupEligibilitySchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop 1145 | [array]$script:myEligibleGroups[$userId] = $myEligibleGroups 1146 | 1147 | } else { 1148 | Write-Host @colorParams "⏳ Not fetching eligible Entra ID groups & their settings as it has only been $minutes since we last checked." 1149 | Write-Host @colorParams "🫵 You can re-run with the -RefreshEligibleGroups switch to force a refresh." 1150 | [array]$myEligibleGroups = $script:myEligibleGroups[$userId] 1151 | } 1152 | 1153 | Write-Progress -Activity "Fetching all active Entra ID groups" -Id 0 1154 | [array]$myActiveGroups = Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentSchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop 1155 | 1156 | } catch { 1157 | throw "Error fetching groups: $($_.Exception.Message)" 1158 | } 1159 | 1160 | Write-Progress -Id 0 -Completed 1161 | 1162 | # Create a cache of assignments. This is faster as I can lookup a bunch of them beforehand. 1163 | $policyAssignmentHashGroupsOwner = @{} 1164 | $policyAssignmentHashGroupsMember = @{} 1165 | # The scopeId is the groupId, so I add this on later 1166 | $searchSnippetMain = "scopeType eq 'Group' and " 1167 | 1168 | # Filter has a max length (not sure what) so I will do it in batches of 5. 1169 | # A temp variable I keep incrementing 1170 | $counter = 0 1171 | # Total number of entries for this scope 1172 | $totalCount = $myEligibleGroups.Count 1173 | 1174 | # Loop through the entries 1175 | # Below doesn't work... loop through each group & do accessId member and owner 1176 | if ($needsUpdating) { 1177 | Write-Host @colorParams "🧙 Fetching all group settings. This will take a few minutes." 1178 | 1179 | foreach ($groupRoleObj in $myEligibleGroups) { 1180 | $counter++ 1181 | $groupId = $groupRoleObj.GroupId 1182 | 1183 | $searchSnippet = $searchSnippetMain + "scopeId eq '$groupId'" 1184 | 1185 | Write-Progress -Activity "$($groupRoleObj.Group.DisplayName)" -Id 0 -Status "${counter}/${totalCount}" -PercentComplete $($counter*100/$totalCount) 1186 | 1187 | # Do the search 1188 | try { 1189 | $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter $searchSnippet -ExpandProperty "policy(`$expand=rules)" -ErrorAction Stop 1190 | 1191 | } catch { 1192 | throw "Error fetching settings assignments: $($_.Exception.Message)" 1193 | } 1194 | 1195 | # And add it to the hash. There are two results - member and owner 1196 | foreach ($result in $policyAssignment) { 1197 | if ($result.RoleDefinitionId -eq "member") { 1198 | $policyAssignmentHashGroupsMember[$groupId] = $result 1199 | 1200 | } elseif ($result.RoleDefinitionId -eq "owner") { 1201 | $policyAssignmentHashGroupsOwner[$groupId] = $result 1202 | 1203 | } 1204 | } 1205 | } 1206 | 1207 | $script:policyAssignmentHashGroupsOwner[$userId] = $policyAssignmentHashGroupsOwner 1208 | $script:policyAssignmentHashGroupsMember[$userId] = $policyAssignmentHashGroupsMember 1209 | 1210 | $script:lastUpdatedGroups[$userId] = $currentTime # Set the lastUpdated timestamp since we have successfully updated the cache 1211 | 1212 | } else { 1213 | $policyAssignmentHashGroupsOwner = $script:policyAssignmentHashGroupsOwner[$userId] 1214 | $policyAssignmentHashGroupsMember = $script:policyAssignmentHashGroupsMember[$userId] 1215 | 1216 | } 1217 | 1218 | Write-Progress -Id 0 -Completed 1219 | } 1220 | 1221 | process { 1222 | Write-Host "" 1223 | 1224 | # Random 12 lower case characters 1225 | # $defaultJustification = -join ((97..122) | Get-Random -Count 12 | ForEach-Object {[char]$_}) 1226 | $defaultJustification = "Activated using Graph.EasyPIM" 1227 | 1228 | if ($env:USERDOMAIN) { 1229 | $userDomain = "$($env:USERDOMAIN)\" 1230 | } 1231 | 1232 | if ($env:USER) { 1233 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USER)" 1234 | } elseif ($env:LOGNAME) { 1235 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:LOGNAME)" 1236 | } elseif ($env:USERNAME) { 1237 | $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USERNAME)" 1238 | } 1239 | 1240 | if ($env:ComputerName) { 1241 | $defaultJustification = $defaultJustification + " on $($env:ComputerName)" 1242 | } 1243 | 1244 | # I use these for showing progress 1245 | [int]$counter = 0 1246 | [int]$totalCount = $myEligibleGroups.Count 1247 | $groupNamesCache = @{} 1248 | 1249 | $groupStates = foreach ($groupRoleObj in $myEligibleGroups) { 1250 | $counter++ 1251 | $percentageComplete = ($counter/$totalCount)*100 1252 | 1253 | $groupId = $groupRoleObj.GroupId 1254 | $groupName = $groupRoleObj.Group.DisplayName 1255 | $groupNamesCache[$groupId] = $groupName 1256 | 1257 | $accessId = $groupRoleObj.AccessId 1258 | 1259 | $timespanArray = @() 1260 | $groupRoleExpired = $false 1261 | $groupRoleAssignmentType = "Inactive" 1262 | 1263 | Write-Progress -Activity "Processing group '$groupName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount" 1264 | 1265 | $activeGroupRoleObj = $null 1266 | $activeGroupRoleObj = $myActiveGroups | Where-Object { $_.GroupId -eq "$groupId" -and $_.AccessId -eq "$accessId" } 1267 | 1268 | if ($activeGroupRoleObj) { 1269 | Write-Progress -Activity "Group is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 1270 | Start-Sleep -Milliseconds 200 # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741 1271 | Write-Progress -Activity "o is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 1272 | 1273 | # Double checking coz during my testing I ran into instances where this was sometimes incomplete 1274 | if ($activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime) { 1275 | $groupRoleAssignmentType = "Active" 1276 | 1277 | $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime 1278 | if ($timeSpan.Days -gt 0) { 1279 | if ($timeSpan.Days -eq 1) { 1280 | $timespanArray += "$($timeSpan.Days) day" 1281 | 1282 | } else { 1283 | $timespanArray += "$($timeSpan.Days) days" 1284 | } 1285 | } 1286 | 1287 | if ($timeSpan.Hours -gt 0) { 1288 | if ($timeSpan.Hours -eq 1) { 1289 | $timespanArray += "$($timeSpan.Hours) hour" 1290 | 1291 | } else { 1292 | $timespanArray += "$($timeSpan.Hours) hours" 1293 | } 1294 | } 1295 | 1296 | if ($timeSpan.Minutes -gt 0) { 1297 | if ($timeSpan.Minutes -eq 1) { 1298 | $timespanArray += "$($timeSpan.Minutes) minute" 1299 | 1300 | } else { 1301 | $timespanArray += "$($timeSpan.Minutes) minutes" 1302 | } 1303 | } 1304 | 1305 | # Just in case there's a delay between getting the states and when I calculate this... 1306 | if ($timeSpan.Ticks -lt 0) { 1307 | $groupRoleExpired = $true 1308 | } 1309 | 1310 | } else { 1311 | $groupRoleExpired = $true 1312 | } 1313 | 1314 | Write-Progress -Id 1 -Completed 1315 | 1316 | } else { 1317 | $groupRoleExpired = $true 1318 | } 1319 | 1320 | # Using the roledefinitionid, find the policy assignment on this role 1321 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyassignment?view=graph-rest-1.0 1322 | 1323 | $policyAssignment = if ($accessId -eq "member") { $policyAssignmentHashGroupsMember[$groupId] } else { $policyAssignmentHashGroupsOwner[$groupId] } 1324 | $policyObj = $policyAssignment.Policy 1325 | 1326 | # The policy is what defines the max duration of the role and other factors. We are interested in here are the rules 1327 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyrule?view=graph-rest-1.0 1328 | 1329 | # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration 1330 | # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0 1331 | $expirationRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties 1332 | 1333 | if ($expirationRule.maximumDuration -match "^PT") { 1334 | # Thanks https://stackoverflow.com/a/57296616 1335 | $timeSpan = [System.Xml.XmlConvert]::ToTimeSpan($expirationRule.maximumDuration) 1336 | 1337 | $maxDurationArray = @() 1338 | 1339 | if ($timeSpan.Days -gt 0) { 1340 | if ($timeSpan.Days -eq 1) { 1341 | $maxDurationArray += "$($timeSpan.Days) day" 1342 | 1343 | } else { 1344 | $maxDurationArray += "$($timeSpan.Days) days" 1345 | } 1346 | } 1347 | 1348 | if ($timeSpan.Hours -gt 0) { 1349 | if ($timeSpan.Hours -eq 1) { 1350 | $maxDurationArray += "$($timeSpan.Hours) hour" 1351 | 1352 | } else { 1353 | $maxDurationArray += "$($timeSpan.Hours) hours" 1354 | } 1355 | } 1356 | 1357 | if ($timeSpan.Minutes -gt 0) { 1358 | if ($timeSpan.Minutes -eq 1) { 1359 | $maxDurationArray += "$($timeSpan.Minutes) minute" 1360 | 1361 | } else { 1362 | $maxDurationArray += "$($timeSpan.Minutes) minutes" 1363 | } 1364 | } 1365 | 1366 | $maxDuration = $maxDurationArray -join ' ' 1367 | 1368 | } else { 1369 | $maxDuration = $expirationRule.maximumDuration 1370 | } 1371 | 1372 | # Repeat, but for the enablement rules 1373 | $enablementRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Enablement_EndUser_Assignment" }).AdditionalProperties.enabledRules 1374 | 1375 | Write-Progress -Completed -Id 1 1376 | 1377 | [pscustomobject][ordered]@{ 1378 | "GroupName" = $groupName 1379 | "Status" = $groupRoleAssignmentType 1380 | "Type" = if ($accessId -eq "member") { "Member" } else { "Owner" } 1381 | "ExpiresIn" = if (!($groupRoleExpired)) { 1382 | # Take only the topmost entry (day or hour in case of more than one) 1383 | if ($timespanArray.Count -gt 1) { 1384 | "~" + $timespanArray[0] 1385 | } else { 1386 | $timespanArray[0] 1387 | } 1388 | } # Tweak the output to to save some space 1389 | 1390 | "MaxDuration" = $maxDuration 1391 | "EnablementRules" = $enablementRule -join '|' -replace 'Justification','Reason' -replace 'Ticketing','Ticket' -replace 'MultiFactorAuthentication','MFA' 1392 | "More" = [pscustomobject]@{ 1393 | "More" = [pscustomobject]@{ 1394 | "AccessId" = $accessId 1395 | "GroupId" = $groupRoleObj.GroupId 1396 | "MaxDuration" = $expirationRule.maximumDuration 1397 | "EnablementRule" = $enablementRule 1398 | "ActiveMinutes" = if (!($groupRoleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeGroupRoleObj.ScheduleInfo.StartDateTime).TotalMinutes } 1399 | } 1400 | } # Two levels to hide this and save some space 1401 | } 1402 | } 1403 | 1404 | Write-Progress -Completed -Id 0 1405 | 1406 | $userSelections = $groupStates | Out-ConsoleGridView -Title "List of active & eligible Entra ID PIM groups (count: $totalCount)" 1407 | 1408 | # Let's ask for the required info upfront 1409 | $justificationsHash = @{} 1410 | $ticketSystemHash = @{} 1411 | $ticketNumberHash = @{} 1412 | 1413 | # I use this for tidying up some of the output later; find the longest entry in the selections 1414 | $longestRoleLength = ($userSelections.GroupName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 1415 | 1416 | $groupsWereDisabled = $false 1417 | foreach ($selection in $userSelections) { 1418 | if ($selection.Status -ne "Inactive") { 1419 | if ($selection.More.More.ActiveMinutes -le 5) { 1420 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1421 | Write-Host "Cannot disable the group as it must be active for at least 5 minutes." 1422 | continue 1423 | } 1424 | 1425 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1426 | Write-Host "Disabling group (so we can enable it again)" 1427 | 1428 | $params = @{ 1429 | accessId = $selection.More.More.AccessId 1430 | action = "selfDeactivate" 1431 | principalId = $userId 1432 | groupId = $selection.More.More.GroupId 1433 | } 1434 | 1435 | try { 1436 | $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 1437 | 1438 | $groupsWereDisabled = $true 1439 | 1440 | } catch { 1441 | Write-Error "Error deactivating '$($selection.GroupName)': $($_.Exception.Message)" 1442 | } 1443 | } 1444 | } 1445 | 1446 | if ($groupsWereDisabled) { 1447 | $counter = 0 1448 | $maxWaitSecs = 20 1449 | while ($counter -lt $maxWaitSecs) { 1450 | Write-Progress "Waiting $maxWaitSecs seconds before continuing" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 1451 | Start-Sleep -Seconds 1 1452 | $counter++ 1453 | } 1454 | 1455 | Write-Progress -Completed 1456 | Write-Host "" 1457 | } 1458 | 1459 | foreach ($selection in $userSelections) { 1460 | # Skip activating active roles that have been active for less than 5 mins 1461 | # Coz we wouldn't have been able to disable them above to reactivate 1462 | if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue } 1463 | 1464 | if ($selection.More.More.EnablementRule -contains "Justification") { 1465 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1466 | 1467 | if ($SkipJustification) { 1468 | $justificationsHash[$($selection.GroupName)] = "$defaultJustification" 1469 | Write-Host "Reason will be set to: $defaultJustification" 1470 | 1471 | } elseif ($Justification.Length -ne 0) { 1472 | $justificationsHash[$($selection.GroupName)] = $Justification 1473 | Write-Host "Reason will be set to: $Justification" 1474 | 1475 | } else { 1476 | $justificationInput = Read-Host "Please provide a reason" 1477 | 1478 | # If the justitication ends with an asterisk or is empty, use it for everything else that follows... 1479 | if ($justificationInput -match '\*$' -or $justificationInput.Length -eq 0) { 1480 | # First, remove the asterisk 1481 | $justificationInput = $justificationInput -replace '\*$','' 1482 | 1483 | # Then check whether anything remains. This is to cater to situations where someone enters * or *** etc. 1484 | # If after removing the asterisk there's nothing, then set it to $defaultJustification for all. This is basically equivalent to -SkipJustification 1485 | if ($justificationInput.Length -eq 0) { 1486 | $justificationInput = "$defaultJustification" 1487 | $justificationsHash[$($selection.GroupName)] = $justificationInput 1488 | } 1489 | 1490 | # Set the justification for everything that follows to be this 1491 | $Justification = $justificationInput 1492 | $justificationsHash[$($selection.GroupName)] = $justificationInput 1493 | 1494 | } else { 1495 | $justificationsHash[$($selection.GroupName)] = $justificationInput 1496 | } 1497 | 1498 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1499 | Write-Host "Reason will be set to: $justificationInput" 1500 | } 1501 | } 1502 | 1503 | if ($selection.More.More.EnablementRule -contains "Ticketing") { 1504 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1505 | 1506 | $ticketNumberHash[$($selection.GroupName)] = Read-Host "Please provide a ticket number" 1507 | 1508 | if ($TicketingSystem.Length -ne 0) { 1509 | Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1510 | $ticketingSystemInput = Read-Host "Please provide the ticketing system name" 1511 | 1512 | # If the justitication ends with an asterisk, use it for everything else that follows... 1513 | if ($ticketingSystemInput -match '\*$') { 1514 | $ticketingSystemInput = $ticketingSystemInput -replace '\*$','' 1515 | $TicketingSystem = $ticketingSystemInput 1516 | } 1517 | 1518 | $ticketSystemHash[$($selection.GroupName)] = $ticketingSystemInput 1519 | 1520 | } else { 1521 | $ticketSystemHash[$($selection.GroupName)] = $TicketingSystem 1522 | } 1523 | } 1524 | } 1525 | 1526 | if ($userSelections.Count -ne 0) { 1527 | Write-Host "" 1528 | } 1529 | 1530 | # An array to capture each of the items we action below 1531 | $requestObjsArray = @() 1532 | 1533 | foreach ($selection in $userSelections) { 1534 | # Skip activating active roles that have been active for less than 5 mins 1535 | # Coz we wouldn't have been able to disable them above to reactivate 1536 | if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue } 1537 | 1538 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1539 | Write-Host "Enabling for $($selection.MaxDuration)" 1540 | 1541 | $params = @{ 1542 | accessId = $selection.More.More.AccessId 1543 | action = "selfActivate" 1544 | principalId = $userId 1545 | groupId = $selection.More.More.GroupId 1546 | 1547 | scheduleInfo = @{ 1548 | startDateTime = Get-Date 1549 | expiration = @{ 1550 | type = "AfterDuration" 1551 | duration = $selection.More.More.MaxDuration 1552 | } 1553 | } 1554 | } 1555 | 1556 | if ($selection.More.More.enablementRule -contains "Justification") { 1557 | $params.justification = $justificationsHash[$($selection.GroupName)] 1558 | } 1559 | 1560 | if ($selection.More.More.enablementRule -contains "Ticketing") { 1561 | $params.ticketInfo = @{ 1562 | ticketNumber = $ticketNumberHash[$($selection.GroupName)] 1563 | ticketSystem = $ticketSystemHash[$($selection.GroupName)] 1564 | } 1565 | } 1566 | 1567 | try { 1568 | $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 1569 | 1570 | # And add it to an array so we can loop over in the end 1571 | $requestObjsArray += $requestObj 1572 | 1573 | } catch { 1574 | Write-Error "Error activating '$($selection.GroupName)': $($_.Exception.Message)" 1575 | } 1576 | } 1577 | 1578 | if ($requestObjsArray.Count -ne 0) { 1579 | Write-Host "" 1580 | 1581 | $counter = 0 1582 | $maxWaitSecs = 20 1583 | while ($counter -lt $maxWaitSecs) { 1584 | Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 1585 | Start-Sleep -Seconds 1 1586 | $counter++ 1587 | } 1588 | 1589 | Write-Progress -Completed 1590 | } 1591 | 1592 | $counter = 0 1593 | $totalCount = $requestObjsArray.Count 1594 | 1595 | $finalOutput = foreach ($requestObj in $requestObjsArray) { 1596 | $counter++ 1597 | Write-Progress "Fetching status of group '$($groupNamesCache[$($requestObj.GroupId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 1598 | 1599 | Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -PrivilegedAccessGroupAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{ 1600 | "Name" = "Group"; 1601 | "Expression" = { $groupNamesCache[$($_.GroupId)] } 1602 | }, @{ 1603 | "Name" = "Type"; 1604 | "Expression" = { if ($_.AccessId -eq "member") { "Member" } else { "Owner" } } 1605 | }, Status 1606 | } 1607 | 1608 | $finalOutput | Format-Table 1609 | } 1610 | } 1611 | 1612 | # This is a copy paste of Enable-PIMRole with some bits removed... 1613 | # It's very simple compared to Enable-PIMRole 1614 | function Disable-PIMGroup { 1615 | param( 1616 | [switch]$UseDeviceCode, 1617 | 1618 | [Parameter(Mandatory=$false)] 1619 | [string]$TenantId, 1620 | 1621 | [Parameter(Mandatory=$false)] 1622 | [string]$ClientId 1623 | ) 1624 | 1625 | <# 1626 | .PARAMETER UseDeviceCode 1627 | Optional. Use Device Code authentication. 1628 | 1629 | .PARAMETER TenantId 1630 | Optional. Use this TenantId. 1631 | 1632 | .PARAMETER ClientId 1633 | Optional. Use this Client Id. 1634 | #> 1635 | 1636 | begin { 1637 | Write-Host "" 1638 | $colorParams = $script:colorParams 1639 | 1640 | [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 1641 | [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version 1642 | 1643 | if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) { 1644 | Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery" 1645 | } 1646 | 1647 | $graphParams = @{ 1648 | "Scopes" = $script:requiredScopesArray 1649 | "NoWelcome" = $true 1650 | "ErrorAction" = "Stop" 1651 | } 1652 | 1653 | if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true } 1654 | if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId } 1655 | if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId } 1656 | 1657 | # Disconnect the existing sessions if one of these were provided 1658 | if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 1659 | try { 1660 | Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null 1661 | } catch {} 1662 | } 1663 | 1664 | try { 1665 | Connect-MgGraph @graphParams 1666 | 1667 | } catch { 1668 | throw "$($_.Exception.Message)" 1669 | } 1670 | 1671 | $context = Get-MgContext 1672 | 1673 | $scopes = $context.scopes 1674 | 1675 | if ($scopes -notcontains "Directory.ReadWrite.All") { 1676 | foreach ($requiredScope in $script:requiredScopesArray) { 1677 | if ($requiredScope -notin $scopes) { 1678 | Write-Warning "Required scope '$requiredScope' missing" 1679 | } 1680 | } 1681 | } 1682 | 1683 | $userId = (Get-MgUser -UserId $context.Account).Id 1684 | 1685 | try { 1686 | Write-Host @colorParams "🥷 Fetching all active Entra ID groups. This is usually pretty quick!" 1687 | 1688 | Write-Progress -Activity "Fetching all active Entra ID groups" -Id 0 1689 | [array]$myActiveGroups = Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentSchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop 1690 | 1691 | } catch { 1692 | throw "Error fetching groups: $($_.Exception.Message)" 1693 | } 1694 | 1695 | Write-Progress -Id 0 -Completed 1696 | } 1697 | 1698 | process { 1699 | Write-Host "" 1700 | 1701 | # I use these for showing progress 1702 | [int]$counter = 0 1703 | [int]$totalCount = $myActiveGroups.Count 1704 | $groupNamesCache = @{} 1705 | 1706 | $groupStates = foreach ($groupRoleObj in $myActiveGroups) { 1707 | $counter++ 1708 | $percentageComplete = ($counter/$totalCount)*100 1709 | 1710 | $groupId = $groupRoleObj.GroupId 1711 | $groupName = $groupRoleObj.Group.DisplayName 1712 | $groupNamesCache[$groupId] = $groupName 1713 | 1714 | $accessId = $groupRoleObj.AccessId 1715 | 1716 | $timespanArray = @() 1717 | $groupRoleExpired = $false 1718 | $groupRoleAssignmentType = "Inactive" 1719 | 1720 | Write-Progress -Activity "Processing group '$groupName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount" 1721 | 1722 | $activeGroupRoleObj = $null 1723 | $activeGroupRoleObj = $myActiveGroups | Where-Object { $_.GroupId -eq "$groupId" -and $_.AccessId -eq "$accessId" } 1724 | 1725 | if ($activeGroupRoleObj) { 1726 | Write-Progress -Activity "Group is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 1727 | Start-Sleep -Milliseconds 200 # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741 1728 | Write-Progress -Activity "o is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete 1729 | 1730 | # Double checking coz during my testing I ran into instances where this was sometimes incomplete 1731 | if ($activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime) { 1732 | $groupRoleAssignmentType = "Active" 1733 | 1734 | $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime 1735 | if ($timeSpan.Days -gt 0) { 1736 | if ($timeSpan.Days -eq 1) { 1737 | $timespanArray += "$($timeSpan.Days) day" 1738 | 1739 | } else { 1740 | $timespanArray += "$($timeSpan.Days) days" 1741 | } 1742 | } 1743 | 1744 | if ($timeSpan.Hours -gt 0) { 1745 | if ($timeSpan.Hours -eq 1) { 1746 | $timespanArray += "$($timeSpan.Hours) hour" 1747 | 1748 | } else { 1749 | $timespanArray += "$($timeSpan.Hours) hours" 1750 | } 1751 | } 1752 | 1753 | if ($timeSpan.Minutes -gt 0) { 1754 | if ($timeSpan.Minutes -eq 1) { 1755 | $timespanArray += "$($timeSpan.Minutes) minute" 1756 | 1757 | } else { 1758 | $timespanArray += "$($timeSpan.Minutes) minutes" 1759 | } 1760 | } 1761 | 1762 | # Just in case there's a delay between getting the states and when I calculate this... 1763 | if ($timeSpan.Ticks -lt 0) { 1764 | $groupRoleExpired = $true 1765 | } 1766 | 1767 | } else { 1768 | $groupRoleExpired = $true 1769 | } 1770 | 1771 | Write-Progress -Id 1 -Completed 1772 | 1773 | } else { 1774 | $groupRoleExpired = $true 1775 | } 1776 | 1777 | [pscustomobject][ordered]@{ 1778 | "GroupName" = $groupName 1779 | "Status" = $groupRoleAssignmentType 1780 | "Type" = if ($accessId -eq "member") { "Member" } else { "Owner" } 1781 | "ExpiresIn" = if (!($groupRoleExpired)) { 1782 | # Take only the topmost entry (day or hour in case of more than one) 1783 | if ($timespanArray.Count -gt 1) { 1784 | "~" + $timespanArray[0] 1785 | } else { 1786 | $timespanArray[0] 1787 | } 1788 | } # Tweak the output to to save some space 1789 | 1790 | "More" = [pscustomobject]@{ 1791 | "More" = [pscustomobject]@{ 1792 | "AccessId" = $accessId 1793 | "GroupId" = $groupRoleObj.GroupId 1794 | "ActiveMinutes" = if (!($groupRoleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeGroupRoleObj.ScheduleInfo.StartDateTime).TotalMinutes } 1795 | } 1796 | } # Two levels to hide this and save some space 1797 | } 1798 | } 1799 | 1800 | Write-Progress -Completed -Id 0 1801 | 1802 | if ($groupStates.Count -eq 0) { 1803 | Write-Host @colorParams ("🚀 No active Entra ID groups found.") 1804 | Write-Host "" 1805 | } 1806 | 1807 | $userSelections = $groupStates | Out-ConsoleGridView -Title "List of active Entra ID PIM groups (count: $totalCount)" 1808 | 1809 | # I use this for tidying up some of the output later; find the longest entry in the selections 1810 | $longestRoleLength = ($userSelections.GroupName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length 1811 | 1812 | # An array to capture each of the items we action below 1813 | $requestObjsArray = @() 1814 | 1815 | foreach ($selection in $userSelections) { 1816 | if ($selection.More.More.ActiveMinutes -le 5) { 1817 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1818 | Write-Host "Cannot disable the group as it must be active for at least 5 minutes." 1819 | continue 1820 | } 1821 | 1822 | Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName)) 1823 | Write-Host "Disabling group" 1824 | 1825 | $params = @{ 1826 | accessId = $selection.More.More.AccessId 1827 | action = "selfDeactivate" 1828 | principalId = $userId 1829 | groupId = $selection.More.More.GroupId 1830 | } 1831 | 1832 | try { 1833 | $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop 1834 | 1835 | # And add it to an array so we can loop over in the end 1836 | $requestObjsArray += $requestObj 1837 | 1838 | } catch { 1839 | Write-Error "Error deactivating '$($selection.GroupName)': $($_.Exception.Message)" 1840 | } 1841 | } 1842 | 1843 | if ($requestObjsArray.Count -ne 0) { 1844 | Write-Host "" 1845 | 1846 | $counter = 0 1847 | $maxWaitSecs = 20 1848 | while ($counter -lt $maxWaitSecs) { 1849 | Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " " 1850 | Start-Sleep -Seconds 1 1851 | $counter++ 1852 | } 1853 | 1854 | Write-Progress -Completed 1855 | } 1856 | 1857 | $counter = 0 1858 | $totalCount = $requestObjsArray.Count 1859 | 1860 | $finalOutput = foreach ($requestObj in $requestObjsArray) { 1861 | $counter++ 1862 | Write-Progress "Fetching status of group '$($groupNamesCache[$($requestObj.GroupId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 1863 | 1864 | Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -PrivilegedAccessGroupAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{ 1865 | "Name" = "Group"; 1866 | "Expression" = { $groupNamesCache[$($_.GroupId)] } 1867 | }, @{ 1868 | "Name" = "Type"; 1869 | "Expression" = { if ($_.AccessId -eq "member") { "Member" } else { "Owner" } } 1870 | }, Status 1871 | } 1872 | 1873 | $finalOutput | Format-Table 1874 | } 1875 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Rakhesh Sasidharan 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graph EasyPIM 2 | Something to make Entra ID PIM easier for end-users. 3 | 4 | You can install the module [from PowerShell Gallery](https://www.powershellgallery.com/packages/Graph.EasyPIM/). 5 | 6 | ```powershell 7 | Install-Module -Name Graph.EasyPIM 8 | ``` 9 | 10 | Not using PowerShell Gallery? Download the source code from this 👇 repo, or get started with PowerShell Gallery following the instructions [here](https://learn.microsoft.com/en-gb/powershell/gallery/getting-started?view=powershellget-3.x). 11 | 12 | Tested on Windows, macOS, and Linux with PowerShell 7.4. It currently has the following cmdlets: 13 | 14 | - `Enable-PIMRole` - enable (activate) Entra ID PIM roles. 15 | - `Enable-PIMGroup` - enable (activate) Entra ID PIM groups. 16 | - `Disable-PIMRole` - disable (deactivate) Entra ID PIM roles. 17 | - `Disable-PIMGroup` - disable (deactivate) Entra ID PIM groups. 18 | 19 | ## Neat features of this module 20 | - You can select more than 1 role or group at a go. Both to activate or deactivate. 21 | - Faster than Entra ID portal in my opinion. There is an initial delay as it pulls all the info, but after that it's pretty fast. 22 | - It always activates the role or group for the maximum allowed duration. 23 | - When selecting roles or groups, if the role or group is already active (and it's been active for more than 5 mins) it will deactivate and activate the role or group. Very useful when you can see a role or group activation is going to expire soon! 24 | - You can skip offering a reason, either via the `-SkipJustification` switch or pressing `ENTER` when asked for one. This will set the reason as `Activated using Graph.EasyPIM by $env:USER on $env:COMPUTERNAME`. 25 | - You can provide a justification before hand via the `-Justification` switch, or by entering one when prompted and adding an asterisk `*` at the end. This will set the same justification for all other roles or groups enabled in that round. 26 | - The [Norton Commander](https://en.wikipedia.org/wiki/Norton_Commander)-ish TUI is a nice trip down memory lane. 🙂 27 | 28 | ## Good to know 29 | - The first time you run one of these cmdlets it will open up a browser window to authenticate. But if you are already connected to Graph, this might not happen and the cmdlets may not work. Do a `Disconnect-MgGraph` and then try the cmdlets again. 30 | - The list of eligible PIM roles are cached for 30 mins. The list of eligible PIM groups are cached for 8 hours. The cmdlets can be run with the `-RefreshEligibleGroup` to force a refresh. 31 | - You might need to involve a Global Admin to do some consents on the `Microsoft Graph Command Line Tools` service principal. To do an admin consent on behalf of the organization, a Global Admin is required; but an Application Admin can do consent for themselves. 32 | - This URL should help: `https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id=14d82eec-204b-4c2f-b7e8-296a70dab67e&scope=RoleEligibilitySchedule.Read.Directory RoleEligibilitySchedule.ReadWrite.Directory RoleManagement.Read.Directory RoleManagement.Read.All RoleManagement.ReadWrite.Directory RoleAssignmentSchedule.ReadWrite.Directory RoleAssignmentSchedule.Remove.Directory PrivilegedEligibilitySchedule.Read.AzureADGroup PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup PrivilegedAccess.Read.AzureADGroup PrivilegedAccess.ReadWrite.AzureADGroup RoleManagementPolicy.Read.AzureADGroup` 33 | - Of course, replace `{tenantId}` above. 34 | - If the preference is to use a custom application, create one following the steps [here](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands?view=graph-powershell-1.0#use-delegated-access-with-a-custom-application-for-microsoft-graph-powershell) and add the permissions above to it. After it is admin consented to, you can connect using `Enable-PIMRole -ClientId -TenantId ` 35 | 36 | ## Pre-requisite modules 37 | This modules depends upon the following. 38 | 39 | - `Microsoft.Graph.Authentication` 40 | - `Microsoft.Graph.Identity.Governance` 41 | - `Microsoft.PowerShell.ConsoleGuiTools` 42 | - `Microsoft.Graph.Users` 43 | - `Microsoft.Graph.Identity.DirectoryManagement` 44 | 45 | ``` 46 | Install-Module "Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.Governance", "Microsoft.Graph.Users", "Microsoft.Graph.Identity.DirectoryManagement", "Microsoft.PowerShell.ConsoleGuiTools" 47 | ``` 48 | 49 | If it weren't for these, this module wouldn't exist! Thank you 😍 to the creators of these, especially `Microsoft.PowerShell.ConsoleGuiTools` which is what I use to drive things. 🙏 50 | 51 | ## Screenshots 52 | (These screenshots are from the first version of this module; the latest versions will have slight differences to what's shown below). 53 | 54 | Running `Enable-PIMRole` lists all the available and active Entra ID PIM roles for the user. 55 | 56 | ![image-20241006172734455](assets/image-20241006172734455.png) 57 | 58 | Press `SPACE` to select one or more entries to activate them. (If a selected role is already active, it is deactivated and reactivated). 59 | 60 | ![image-20241006172840346](assets/image-20241006172840346.png) 61 | 62 | Press `ENTER`. This is what starts the activation process. The previous step only selects the ones we wish to activate. 63 | 64 | Enter a reason or ticket number if the role requires it. 65 | 66 | ![image-20241006173010679](assets/image-20241006173010679.png) 67 | 68 | Wait a bit for it to show the final status. 69 | 70 | ![image-20241006173033656](assets/image-20241006173033656.png) 71 | 72 | That's it! 73 | 74 | Way faster than the Entra ID portal. And you can select more than 1 role at a go. 75 | 76 | ## API reference 77 | - [PIM for Entra roles](https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagementv3-overview?view=graph-rest-1.0) 78 | - [PIM for Groups](https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagement-for-groups-api-overview?view=graph-rest-1.0) 79 | 80 | ![Static Badge](https://img.shields.io/badge/mentioned%20in-x) [![Static Badge](https://img.shields.io/badge/65-x?label=entra%20news&link=https%3A%2F%2Fentra.news%2Fp%2Fentra-id-news-65-this-week-in-microsoft%3Fopen%3Dfalse%23%25C2%25A7learn)](https://entra.news/p/entra-id-news-65-this-week-in-microsoft?open=false#%C2%A7learn) [![Static Badge](https://img.shields.io/badge/66-x?label=entra%20news&link=https%3A%2F%2Fentra.news%2Fp%2Fentra-news-66-this-week-in-microsoft%3Fopen%3Dfalse%23%25C2%25A7from-the-community)](https://entra.news/p/entra-news-66-this-week-in-microsoft?open=false#%C2%A7from-the-community) -------------------------------------------------------------------------------- /assets/image-20241006172734455.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakheshster/PowerShell-GraphEasyPIM/a7de38e70ce57a5b866118829f5c2ada2f5dc119/assets/image-20241006172734455.png -------------------------------------------------------------------------------- /assets/image-20241006172840346.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakheshster/PowerShell-GraphEasyPIM/a7de38e70ce57a5b866118829f5c2ada2f5dc119/assets/image-20241006172840346.png -------------------------------------------------------------------------------- /assets/image-20241006173010679.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakheshster/PowerShell-GraphEasyPIM/a7de38e70ce57a5b866118829f5c2ada2f5dc119/assets/image-20241006173010679.png -------------------------------------------------------------------------------- /assets/image-20241006173033656.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakheshster/PowerShell-GraphEasyPIM/a7de38e70ce57a5b866118829f5c2ada2f5dc119/assets/image-20241006173033656.png --------------------------------------------------------------------------------