EntraReporter.psm1
|
$script:ModuleRoot = $PSScriptRoot <# .SYNOPSIS Returns a list of Azure AD administrative units (plus the root directory scope) formatted for EntraReporter. .DESCRIPTION Retrieves all administrative units from Microsoft Graph using Invoke-MgGraphRequest and converts each item into a PSCustomObject with directoryScopeId and displayName. .PARAMETER None This cmdlet does not take any parameters. .OUTPUTS System.Management.Automation.PSCustomObject Contains properties: - directoryScopeId: String, e.g. '/administrativeUnits/{id}' or '/' - displayName: String, administrative unit display name or 'Directory' .EXAMPLE Get-AdministrativeUnit Returns all administrative units and the root directory scope. .NOTES Part of EntraReporter internal functions. #> function Get-AdministrativeUnit { [CmdletBinding()] $results = @() # Get all administrative units from directory to avoid making multiple calls $allAdministrativeUnits = (Invoke-MgGraphRequest -Method GET -Uri 'v1.0/directory/administrativeUnits' -Verbose:$false)['value'] foreach ($adminUnit in $allAdministrativeUnits) { $results += [pscustomobject] @{ directoryScopeId = "/administrativeUnits/$($adminUnit.id)" displayName = $adminUnit.displayName } } $results += [pscustomobject] @{ directoryScopeId = '/' displayName = 'Directory' } return $results } <# .SYNOPSIS Retrieves membership role assignment or eligibility schedules for multiple Azure AD groups using batched Graph API requests. .DESCRIPTION Fetches PIM group membership schedules for a collection of group IDs. Supports both active assignments and eligible memberships. The function splits the input group IDs into chunks of 20 to optimize Graph API batch requests and handles error recovery per chunk to avoid total failure if some group IDs fail. .PARAMETER GroupId One or more Azure AD group IDs for which to retrieve schedules. The function will batch these requests for efficiency. .PARAMETER State The type of schedule to retrieve: 'Assigned' for active membership schedules, or 'Eligible' for eligible membership schedules. .OUTPUTS PSCustomObject array containing schedule records with principal expansion. Each record includes properties like principalId, groupId, scheduleInfo, and principal (expanded user/group details). .EXAMPLE Get-EntraIdGroupScheduleBatch -GroupId @('group-id-1', 'group-id-2') -State 'Assigned' Retrieves active membership assignment schedules for the specified groups. .NOTES Requires an active Microsoft Graph connection. Uses batched requests (20 IDs per batch) to optimize API throughput. Errors on individual chunks are logged as warnings but do not halt the overall operation. #> function Get-EntraIdGroupScheduleBatch { [CmdletBinding()] param( # Array of Azure AD group IDs to fetch schedules for [Parameter(Mandatory = $true)] [string[]] $GroupId, # Type of schedule: 'Assigned' for active, 'Eligible' for eligible memberships [Parameter(Mandatory = $true)] [ValidateSet('Assigned', 'Eligible')] [string] $State ) # Select the appropriate Graph API endpoint based on the requested state switch ($State) { 'Assigned' { # Endpoint for active group membership assignments $urlTemplate = "/identityGovernance/privilegedAccess/group/assignmentSchedules?`$filter=groupId eq '{Id}'&`$expand=principal" } 'Eligible' { # Endpoint for eligible group membership schedules $urlTemplate = "/identityGovernance/privilegedAccess/group/eligibilitySchedules?`$filter=groupId eq '{Id}'&`$expand=principal" } } # Split the GroupId array into chunks of 20 for batch processing (Graph API batch limit optimization) $chunks = Split-ArrayIntoChunk -InputObject $GroupId -ChunkSize 20 # Process each chunk through the Graph batch API and collect all responses $allResponses = foreach ($chunk in $chunks) { try { # Build batch request objects for each group ID in the current chunk $requests = foreach ($id in $chunk) { @{ id = $id method = 'GET' url = $urlTemplate.Replace('{Id}', $id) } } # Submit batch request for this chunk and retrieve responses Invoke-GraphBatch -Requests $requests -ThrowOnAnyError } catch { # Log warning if chunk processing fails, but continue with remaining chunks Write-Warning ("Failed to process chunk of group IDs '{0}': {1}" -f ($chunk -join ', '), $_.Exception.Message) } } # Extract the actual schedule data from batch responses and flatten the results $allResponses.responses | ForEach-Object { $_.body.value } } <# .SYNOPSIS Executes a Microsoft Graph batch request and optionally fails on any non-success status code. .DESCRIPTION Sends a batch of individual Graph API requests in a single HTTP POST to reduce round-trips. Returns the full Graph batch response. .PARAMETER Requests An array of hashtables representing Graph batch requests. Each hashtable must include id, method, and url. .PARAMETER GraphVersion Graph API version to call (default: 'v1.0'). .PARAMETER ThrowOnAnyError If specified, throws an exception when any response entry has HTTP status 400 or greater. .OUTPUTS System.Object (Graph batch response) .EXAMPLE $batch = @( @{ id = '1'; method = 'GET'; url = '/users' } @{ id = '2'; method = 'GET'; url = '/groups' } ) Invoke-GraphBatch -Requests $batch -GraphVersion 'v1.0' -ThrowOnAnyError Performs the specified batch request against Microsoft Graph and throws if any individual request fails. .NOTES Used by EntraReporter internal routines to perform Graph batch calls. #> function Invoke-GraphBatch { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable[]]$Requests, [Parameter()] [string]$GraphVersion = 'v1.0', [Parameter()] [switch]$ThrowOnAnyError ) $uri = "$GraphVersion/`$batch" $body = @{ requests = $Requests } $resp = Invoke-MgGraphRequest -Method POST -Uri $uri -Body $body -Verbose:$false if ($ThrowOnAnyError) { $errors = $resp.responses | Where-Object { $_.status -ge 400 } if ($errors) { $codes = ($errors | ForEach-Object { "$($_.id):$($_.status)" }) -join ', ' throw "One or more batch requests failed: $codes" } } return $resp } <# .SYNOPSIS Retrieves all items from a paged Microsoft Graph endpoint by following @odata.nextLink. .DESCRIPTION Performs repeated GET calls to the provided Graph URI and collects results from each page. Appends all `value` arrays into a single output array. This supports Graph endpoints that return paged data. .PARAMETER Uri The initial Microsoft Graph URI to request (e.g. 'https://graph.microsoft.com/v1.0/users'). .OUTPUTS System.Object[] An array of objects returned from Graph pages. .EXAMPLE Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/users' Returns all users from the tenant by following pagination links. .NOTES Uses Invoke-MgGraphRequest to perform each request and expects standard Graph pagination (`@odata.nextLink`). #> function Invoke-GraphPaged { [CmdletBinding()] [OutputType([System.Object[]])] param( [Parameter(Mandatory)] [string]$Uri ) $items = @() $next = $Uri while ($next) { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -ErrorAction Stop if ($resp.value) { $items += $resp.value } $next = $resp.'@odata.nextLink' } return , $items } <# .SYNOPSIS Splits an array into multiple chunks of a specified size. .DESCRIPTION Takes an input array and divides it into smaller arrays (chunks). Each returned chunk is a fixed-size object[] (except the last chunk) and the function returns a collection of chunk arrays. Useful for batching operations. .PARAMETER InputObject The array of values to split into chunks. Cannot be null or empty. .PARAMETER ChunkSize The maximum number of items per chunk. Must be an integer greater than or equal to 1. .OUTPUTS System.Collections.Generic.List[object[]] A list of object arrays, where each array is a chunk from the original input. .EXAMPLE Split-ArrayIntoChunk -InputObject @(1,2,3,4,5) -ChunkSize 2 Returns chunks: @(1,2), @(3,4), @(5) .NOTES Used by EntraReporter helpers for batching Graph requests. #> function Split-ArrayIntoChunk { [CmdletBinding()] [OutputType([System.Array])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [object[]]$InputObject, [ValidateRange(1, [int]::MaxValue)] [int]$ChunkSize = 20 ) $items = @($InputObject) # Always return a collection object $result = New-Object System.Collections.Generic.List[object[]] if ($items.Count -gt 0) { for ($i = 0; $i -lt $items.Count; $i += $ChunkSize) { $end = [Math]::Min($i + $ChunkSize - 1, $items.Count - 1) [void]$result.Add(@($items[$i..$end])) # ensure each chunk is an object[] } } # Emit the *collection* as a single object so callers get one wrapper , $result } <# .SYNOPSIS Test Microsoft Graph connection .DESCRIPTION Verifies that the Microsoft.Graph.Authentication module is installed and that the session is connected to Microsoft Graph. .EXAMPLE Test-GraphConnection Returns true if connected to Graph, false otherwise. Used by EntraReporter internal routines to check Graph connectivity before making API calls. #> function Test-GraphConnection { [CmdletBinding()] [OutputType([System.Boolean])] param () # Check if Graph module is installed if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication -Verbose:$false)) { Write-Verbose 'Microsoft.Graph.Authentication module is not installed.' return $false } # Check if connected $mgContext = Get-MgContext -ErrorAction SilentlyContinue if (-not $mgContext) { Write-Verbose 'Not connected to Microsoft Graph.' return $false } Write-Verbose 'Connected to Microsoft Graph.' return $true } <# .SYNOPSIS Determines the Entra ID license level (Free, P1, or P2) for the connected tenant. .DESCRIPTION Queries the tenant's Microsoft Graph SKU information to determine which premium Entra ID licenses are enabled. Returns the highest available license level (P2 > P1 > Free). Optionally includes detailed information about provisioned and consumed license units. .PARAMETER IncludeDetails When specified, includes detailed license counts (enabled and assigned units) and SKU part numbers for P1 and P2 plans. .OUTPUTS PSCustomObject with properties: - Level: 'Free', 'P1', or 'P2' (highest available) - IsP1Available: $true if P1 is licensed and active - IsP2Available: $true if P2 is licensed and active - P1EnabledCount: (with -IncludeDetails) Total P1 licenses enabled - P2EnabledCount: (with -IncludeDetails) Total P2 licenses enabled - P1AssignedCount: (with -IncludeDetails) Currently consumed P1 units - P2AssignedCount: (with -IncludeDetails) Currently consumed P2 units - P1SkuPartNumbers: (with -IncludeDetails) Unique P1 SKU part numbers - P2SkuPartNumbers: (with -IncludeDetails) Unique P2 SKU part numbers .EXAMPLE Get-EntraIdLevel Returns the license level for the tenant. .EXAMPLE Get-EntraIdLevel -IncludeDetails Returns the license level along with detailed unit and SKU information. .NOTES Requires an active Microsoft Graph connection. Uses v1.0 subscription API endpoint. #> function Get-EntraIdLevel { [CmdletBinding()] param( # When set, includes enabled/assigned counts and SKU part numbers in output [switch] $IncludeDetails ) # Fetch all subscribed SKUs from Graph API using v1.0 endpoint for stability $skus = Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -Verbose:$false # Handle edge case: if tenant has no SKUs (rare), default to Free tier if (-not $skus -or $skus.Count -eq 0) { # No SKUs found; initialize result with Free tier $result = [ordered]@{ Level = 'Free' IsP1Available = $false IsP2Available = $false } if ($IncludeDetails) { # Add detailed counts and SKU info (all zeros/empty for Free tier) $result.P1EnabledCount = 0 $result.P2EnabledCount = 0 $result.P1AssignedCount = 0 $result.P2AssignedCount = 0 $result.P1SkuPartNumbers = @() $result.P2SkuPartNumbers = @() } return [PSCustomObject]$result } # Identify P1 and P2 licensed SKUs (filter by service plan and provisioning status) $p1Skus = $skus | Where-Object { $_.servicePlans | Where-Object { $_.servicePlanName -eq 'AAD_PREMIUM' -and $_.provisioningStatus -eq 'Success' } } $p2Skus = $skus | Where-Object { $_.servicePlans | Where-Object { $_.servicePlanName -eq 'AAD_PREMIUM_P2' -and $_.provisioningStatus -eq 'Success' } } # Sum total enabled licenses (capacity purchased for each tier) $p1Enabled = ($p1Skus | ForEach-Object { $_.prepaidUnits.enabled } | Measure-Object -Sum).Sum $p2Enabled = ($p2Skus | ForEach-Object { $_.prepaidUnits.enabled } | Measure-Object -Sum).Sum # Sum consumed/assigned units (how many are currently in use) $p1Assigned = ($p1Skus | ForEach-Object { $_.consumedUnits } | Measure-Object -Sum).Sum $p2Assigned = ($p2Skus | ForEach-Object { $_.consumedUnits } | Measure-Object -Sum).Sum # Determine the effective license level: P2 (highest) > P1 > Free $level = if ($p2Enabled -gt 0) { 'P2' } elseif ($p1Enabled -gt 0) { 'P1' } else { 'Free' } # Build the result object with availability flags $result = [ordered]@{ Level = $level IsP1Available = ($p1Enabled -gt 0) IsP2Available = ($p2Enabled -gt 0) } if ($IncludeDetails) { # Convert sums to int and populate unit counts $result.P1EnabledCount = [int]($p1Enabled | ForEach-Object { $_ } ) $result.P2EnabledCount = [int]($p2Enabled | ForEach-Object { $_ } ) $result.P1AssignedCount = [int]($p1Assigned | ForEach-Object { $_ } ) $result.P2AssignedCount = [int]($p2Assigned | ForEach-Object { $_ } ) # Extract and deduplicate SKU part numbers for reporting $result.P1SkuPartNumbers = $p1Skus.skuPartNumber | Sort-Object -Unique $result.P2SkuPartNumbers = $p2Skus.skuPartNumber | Sort-Object -Unique } # Return the result as a PowerShell custom object with all properties [PSCustomObject]$result } <# .SYNOPSIS Retrieves Entra ID role assignments and eligibility information for users, groups, and service principals. .DESCRIPTION The command queries Microsoft Graph Privileged Identity Management (PIM) role assignment and role eligibility schedules, resolves group member assignments, and returns a consolidated set of role assignment records. .PARAMETER RoleName Optional filter for one or more role names. If provided, only role assignments and eligibilities matching the specified role names are returned. .EXAMPLE Get-EntraIdRoleAssignment Retrieves all role assignments and eligibilities in the connected tenant (requires Entra P2). .EXAMPLE Get-EntraIdRoleAssignment -RoleName 'Global Administrator' Retrieves assignments and eligibilities for the Global Administrator role only. .NOTES Requires an active connection to Microsoft Graph (`Connect-MgGraph`) and Entra P2 license level. This module does not yet support nested group role assignments completely; a warning is emitted when nested groups are present. .LINK https://learn.microsoft.com/graph/api/rolemanagement-root #> function Get-EntraIdRoleAssignment { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter()] [string[]] $RoleName ) # TODO: Add switch to include expired assigments. These are not included by default and may require additional API calls to fetch historical assignment data, so we will implement this as an optional switch to avoid unnecessary API calls for users who do not need this information. # TODO: Consider options for adding a property showing whether accounts have sign-in disabled, stop showing them default, and add switch to include them. This will require fetching additional information about the principal (user/service principal) for each assignment to check the sign-in status, which may lead to a significant increase in API calls. #region Internal functions # Resolve-AssignmentWindow: compute the effective assignment window from principal and user time windows function Resolve-AssignmentWindow { [CmdletBinding()] param( [Nullable[datetime]] $PrincipalStart, # Start datetime from principal assignment schedule [Nullable[datetime]] $PrincipalEnd, # End datetime from principal assignment schedule [Nullable[datetime]] $UserStart, # Start datetime from user assignment schedule [Nullable[datetime]] $UserEnd # End datetime from user assignment schedule ) if ($null -eq $UserEnd -and $null -eq $PrincipalEnd) { # Both are permanent (no end date); assignment is permanent return [pscustomobject]@{ Start = $null End = $null IsPermanent = $true } } if ($null -eq $PrincipalEnd) { # Principal is permanent; use user window return [pscustomobject]@{ Start = $UserStart End = $UserEnd IsPermanent = $false } } if ($null -eq $UserEnd) { # User is permanent; use principal window return [pscustomobject]@{ Start = $PrincipalStart End = $PrincipalEnd IsPermanent = $false } } # Both have end dates; return intersection (most restrictive window) return [pscustomobject]@{ Start = if ($PrincipalStart -gt $UserStart) { $PrincipalStart } else { $UserStart } End = if ($PrincipalEnd -lt $UserEnd) { $PrincipalEnd } else { $UserEnd } IsPermanent = $false } } # New-RoleAssignmentEntry: build and normalize a role assignment output record function New-RoleAssignmentEntry { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Doesnt really change state, just normalizes output')] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RoleId, # Role definition ID [Parameter(Mandatory)] [string]$RoleName, # Role display name [Parameter(Mandatory)] [string]$PrincipalId, # Assigned principal ID (user/group/servicePrincipal) [Parameter(Mandatory)] [string]$PrincipalDisplayName,# Assigned principal display name [Parameter(Mandatory)] [string]$AssignmentType, # Assignment type (User/Group/Service principal) [Parameter(Mandatory)] [string]$Scope, # Scope/administrative unit for assignment [Parameter(Mandatory)] [string]$PrincipalState, # Principal state (Assigned/Eligible) [Nullable[datetime]] $PrincipalStartTime, # Principal assignment start [Nullable[datetime]] $PrincipalEndTime, # Principal assignment end [Parameter(Mandatory)] [string]$UserId, # User ID (expanded member if group schedule) [Parameter(Mandatory)] [string]$UserDisplayName, # User display name [Parameter(Mandatory)] [string]$UserPrincipalName, # User principal name/appId [Parameter(Mandatory)] [string]$UserState, # User state (Assigned/Eligible) [Nullable[datetime]] $UserStartTime, # User assignment start [Nullable[datetime]] $UserEndTime # User assignment end ) # Resolve effective assignment window by calculating the intersection of the principal and user assignment windows. If either of the windows is permanent (i.e. has no end time), the effective window will be determined by the other window. If both windows are permanent, the effective window will also be permanent. $window = Resolve-AssignmentWindow -PrincipalStart $PrincipalStartTime -PrincipalEnd $PrincipalEndTime -UserStart $UserStartTime -UserEnd $UserEndTime # Determine effective state. If both principal and user are assigned, the effective state is assigned. In all other cases (e.g. one of them is eligible or both are eligible), the effective state is eligible. $effectiveState = if ($PrincipalState -eq 'Assigned' -and $UserState -eq 'Assigned') { 'Assigned' } else { 'Eligible' } # Emit a single entry for the combination of principal and user with the resolved effective assignment window and state. [PSCustomObject]@{ PSTypeName = 'EntraReporter.RoleAssignment' RoleId = $RoleId RoleName = $RoleName PrincipalId = $PrincipalId PrincipalDisplayName = $PrincipalDisplayName AssignmentType = $AssignmentType Scope = $Scope PrincipalState = $PrincipalState PrincipalStartTime = $PrincipalStartTime PrincipalEndTime = $PrincipalEndTime UserId = $UserId UserDisplayName = $UserDisplayName UserPrincipalName = $UserPrincipalName UserState = $UserState UserStartTime = $UserStartTime UserEndTime = $UserEndTime EffectiveState = $effectiveState EffectiveStartTime = $window.Start EffectiveEndTime = $window.End IsPermanent = $window.IsPermanent } } # Resolve-RoleAssignedGroup: expand group principal schedules into per-member entries # This function reads the preloaded group schedule entries, maps each member to user-level # role assignment/eligibility and then calls New-RoleAssignmentEntry to normalize output. function Resolve-RoleAssignedGroup { [CmdletBinding()] param( # Graph schedule object for group role assignment/eligibility [Parameter(Mandatory = $true)] [psobject] $Schedule, # Group state context being processed [Parameter(Mandatory = $true)] [ValidateSet('Assigned', 'Eligible')] [string] $GroupState, # Resulting user-level state for group members [Parameter(Mandatory = $true)] [ValidateSet('Assigned', 'Eligible')] [string] $UserState ) $principal = $Schedule.principal # Choose group schedule entries based on whether we are resolving assigned or eligible members if ($UserState -eq 'Assigned') { $scheduleEntries = $script:groupAssignmentSchedules | Where-Object { $_.groupId -eq $principal.id } } else { $scheduleEntries = $script:groupEligibilitySchedules | Where-Object { $_.groupId -eq $principal.id } } foreach ($scheduleEntry in $scheduleEntries ) { # Ignore eligble entries that has been activated to avoid emitting duplicate entries for the same user that would show up as both eligible and assigned. if ($scheduleEntry.assignmentType -eq 'activated') { continue } if ($scheduleEntry.principal.'@odata.type' -eq '#microsoft.graph.user') { # User member found; create normalized assignment entry $roleEntrySplat = @{ RoleId = $Schedule.roleDefinitionId RoleName = $Schedule.roleDefinition.displayName PrincipalId = $Schedule.principal.id PrincipalDisplayName = $Schedule.principal.displayName AssignmentType = 'Group' Scope = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName PrincipalState = $GroupState PrincipalStartTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null } PrincipalEndTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null } UserId = $scheduleEntry.principalId UserDisplayName = $scheduleEntry.principal.displayName UserPrincipalName = $scheduleEntry.principal.userPrincipalName UserState = $UserState UserStartTime = if ($scheduleEntry.scheduleInfo.expiration.endDateTime) { $scheduleEntry.scheduleInfo.startDateTime } else { $null } UserEndTime = if ($scheduleEntry.scheduleInfo.expiration.endDateTime) { $scheduleEntry.scheduleInfo.expiration.endDateTime } else { $null } } New-RoleAssignmentEntry @roleEntrySplat } elseif ($scheduleEntry.principal.'@odata.type' -eq '#microsoft.graph.group') { # Nested group found; recursion not yet supported Write-Verbose "Skipping nested group with ID '$($scheduleEntry.principal.id)' since nested groups are currently not supported by the command." } else { Write-Warning "Principal with ID '$($scheduleEntry.principal.id)' is of type $($scheduleEntry.principal.'@odata.type'), which is currently not supported by the command. Skipping entry." $scheduleEntry | ConvertTo-Json -Depth 5 | Write-Verbose } } } # Resolve-EntraIDRoleSchedule: normalize a schedule entry into 1+ role assignment rows # Handles user/servicePrincipal directly and delegates group principals to Resolve-RoleAssignedGroup. function Resolve-EntraIDRoleSchedule { param( # The Graph role schedule to resolve into output entries [Parameter(Mandatory = $true)] [psobject] $Schedule, # The schedule state to apply for this resolution pass [Parameter(Mandatory = $true)] [ValidateSet('Assigned', 'Eligible')] [string] $State ) # Resolve principal to allow handling of different principal types # (user, servicePrincipal, group) and convert each to normalized rows. try { $principal = $Schedule.principal if ($principal.'@odata.type' -eq '#microsoft.graph.user') { # User principal; create entry with user as both principal and assignee $roleEntrySplat = @{ RoleId = $Schedule.roleDefinitionId RoleName = $Schedule.roleDefinition.displayName PrincipalId = $Schedule.principal.id PrincipalDisplayName = $Schedule.principal.displayName AssignmentType = 'User' Scope = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName PrincipalState = $State PrincipalStartTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null } PrincipalEndTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null } UserId = $Schedule.principalId UserDisplayName = $Schedule.principal.displayName UserPrincipalName = $Schedule.principal.userPrincipalName UserState = $State UserStartTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null } UserEndTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null } } New-RoleAssignmentEntry @roleEntrySplat } elseif ($principal.'@odata.type' -eq '#microsoft.graph.servicePrincipal') { # Service principal; create entry with appId as principal name $roleEntrySplat = @{ RoleId = $Schedule.roleDefinitionId RoleName = $Schedule.roleDefinition.displayName PrincipalId = $Schedule.principal.id PrincipalDisplayName = $Schedule.principal.displayName AssignmentType = 'Service principal' Scope = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName PrincipalState = $State PrincipalStartTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null } PrincipalEndTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null } UserId = $Schedule.principalId UserDisplayName = $Schedule.principal.displayName UserPrincipalName = $Schedule.principal.appId UserState = $State UserStartTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null } UserEndTime = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null } } New-RoleAssignmentEntry @roleEntrySplat } elseif ($principal.'@odata.type' -eq '#microsoft.graph.group') { # Group principal; expand into per-member entries, handling both assigned and eligible Resolve-RoleAssignedGroup -Schedule $Schedule -GroupState $State -UserState 'Assigned' Resolve-RoleAssignedGroup -Schedule $Schedule -GroupState $State -UserState 'Eligible' } else { Write-Warning "Principal with ID '$($principal.id)' is of type $($principal.'@odata.type'), which is currently not supported by the command. Skipping entry." $Schedule | ConvertTo-Json -Depth 5 -Compress | Write-Verbose } } catch { Write-Warning "An error occurred while processing principal with ID '$($principal.id)' for role assignment with role ID '$($Schedule.roleDefinitionId)'. Skipping entry. Error details: $_" $Schedule | ConvertTo-Json -Depth 5 -Compress | Write-Verbose } } #endregion Internal Functions #region MAIN # Always stop on errors to avoid emitting incomplete data. Errors should be handled at the command level to allow for more granular error handling (e.g. skipping individual entries that fail to resolve rather than failing the entire command). $ErrorActionPreference = 'Stop' if (!(Test-GraphConnection)) { throw 'No active connection to Microsoft Graph. Run Connect-MgGraph to sign in and then retry.' } $EntraIdLevel = Get-EntraIdLevel if ($EntraIdLevel.Level -ne 'P2') { throw 'This command requires at P2 level to run since it relies on APIs that are not available for Entra P1 or Free tenants.' # TODO: Add support for P1 tenants by falling back to fetching role assignments via the standard directory role assignments API for tenants that do not have PIM / Privileged Access enabled. This will likely require significant changes to the command logic since the standard directory role assignments API does not return future-dated assignments or eligible assignments, so it will require fetching all role assignments and then checking each assignment against the role eligibility schedules to determine eligibility and future-dated status. In the meantime, we will throw an error for non-P2 tenants to avoid emitting incomplete data. } $timer = [Diagnostics.Stopwatch]::StartNew() $activityName = 'Fetching Entra ID role assignments' # Fetch all role schedules, assigned and eligible. Write-Progress -Activity $activityName -Status 'Fetching assigned role schedules' -PercentComplete 20 $roleAssignmentSchedules = (Invoke-MgGraphRequest -Method GET -Uri "v1.0/roleManagement/directory/roleAssignmentSchedules?`$filter=assignmentType eq 'Assigned'&`$expand=principal,roleDefinition" -Verbose:$false)['value'] Write-Progress -Activity $activityName -Status 'Fetching eligible role schedules' -PercentComplete 40 $roleEligibilitySchedules = (Invoke-MgGraphRequest -Method GET -Uri "v1.0/roleManagement/directory/roleEligibilitySchedules?`$expand=principal,roleDefinition" -Verbose:$false)['value'] # If Rolename has been specified, filter out any assigments not in the specified role(s). if ($RoleName) { $roleAssignmentSchedules = $roleAssignmentSchedules | Where-Object { $_.roleDefinition.displayName -in $RoleName } $roleEligibilitySchedules = $roleEligibilitySchedules | Where-Object { $_.roleDefinition.displayName -in $RoleName } } # If any scopes are used in the role schedules (i.e. scope is not just the entire directory), fetch information about the scopes to allow for better reporting (e.g. resolving administrative unit names). Write-Progress -Activity $activityName -Status 'Fetching scope information' -PercentComplete 60 $script:administrativeUnits = Get-AdministrativeUnit # Extract unique group IDs from all role schedules for prefetching group schedule information in bulk to reduce number of API calls later on. $groupIds = @() $groupIds += ($roleAssignmentSchedules.principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }).Id $groupIds += ($roleEligibilitySchedules.principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }).Id $groupIds = $groupIds | Select-Object -Unique # Prefetch group schedules for all groups used groups if ($groupIds.Count -gt 0) { Write-Progress -Activity $activityName -Status 'Fetching assigned group schedules' -PercentComplete 65 $script:groupAssignmentSchedules = Get-EntraIdGroupScheduleBatch -GroupId $groupIds -State 'Assigned' Write-Progress -Activity $activityName -Status 'Fetching eligible group schedules' -PercentComplete 80 $script:groupEligibilitySchedules = Get-EntraIdGroupScheduleBatch -GroupId $groupIds -State 'Eligible' } else { $script:groupAssignmentSchedules = @() $script:groupEligibilitySchedules = @() } if (($groupAssignmentSchedules | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.group' }) -or ($groupEligibilitySchedules | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.group' })) { Write-Warning 'One or more role assignment uses nested groups, which is currently not supported by the command. This may lead to incomplete reporting.' # TODO: Add support for nested groups by recursively resolving group memberships until only user principals are left. This will likely require a significant increase in the number of API calls, so it should be implemented with caution and ideally with some form of caching to avoid hitting API limits. In the meantime, we will emit a warning to alert users of potential incompleteness in the reporting. } Write-Progress -Activity $activityName -Status 'Consolidating results' -PercentComplete 95 $resolvedGroupSchedules = @() $resolvedGroupSchedules += foreach ($schedule in $roleAssignmentSchedules) { Resolve-EntraIDRoleSchedule -Schedule $schedule -State 'Assigned' } $resolvedGroupSchedules += foreach ($schedule in $roleEligibilitySchedules) { Resolve-EntraIDRoleSchedule -Schedule $schedule -State 'Eligible' } Write-Progress -Activity $activityName -Completed $resolvedGroupSchedules | Sort-Object -Property RoleName, UserDisplayName Write-Verbose "Total execution time: $($timer.Elapsed.TotalSeconds) seconds" } #endregion MAIN # Commands run on module import go here # E.g. Argument Completers could be placed here # Module-wide variables go here # For example if you want to cache some data, have some module-wide config settings, etc. ... those could go here # Example: # $script:config = @{ } Export-ModuleMember -Function 'Get-EntraIdLevel','Get-EntraIdRoleAssignment' |