Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.2.0 (2026-04-04)

### Fixed

- List of required Graph scopes was missing scopes.

### Added

- Better error handling due to missing permissions or unknown objects
- Requirement for module 'Microsoft.Graph.Authentication'

## 0.1.0 (2026-04-03)

### Added
Expand Down
8 changes: 5 additions & 3 deletions EntraReporter/EntraReporter.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
RootModule = 'EntraReporter.psm1'

# Version number of this module.
ModuleVersion = '0.1.0'
ModuleVersion = '0.2.0'

# Supported PSEditions
# CompatiblePSEditions = @()
Expand Down Expand Up @@ -43,7 +43,9 @@
# ProcessorArchitecture = ''

# Modules that must be imported into the global environment prior to importing this module
# RequiredModules = @()
RequiredModules = @(
'Microsoft.Graph.Authentication'
)

# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()
Expand Down Expand Up @@ -92,7 +94,7 @@
PSData = @{

# Tags applied to this module. These help with module discovery in online galleries.
# Tags = @()
Tags = 'Identity', 'Entra', 'EntraId', 'AzureAD', 'AAD', 'Reporting', 'Graph', 'MicrosoftGraph'

# A URL to the license for this module.
LicenseUri = 'https://github.com/kovergard/EntraReporter/blob/main/LICENSE'
Expand Down
123 changes: 65 additions & 58 deletions EntraReporter/functions/Get-EntraIdRoleAssignment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -224,59 +224,65 @@ function Get-EntraIdRoleAssignment {

# Resolve principal to allow handling of different principal types
# (user, servicePrincipal, group) and convert each to normalized rows.
$principal = $Schedule.principal
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 } | Select-Object -ExpandProperty 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 }
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 } | Select-Object -ExpandProperty 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
}
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 } | Select-Object -ExpandProperty 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 }
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 } | Select-Object -ExpandProperty 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
}
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'
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
}
}
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 | 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
}
}

Expand Down Expand Up @@ -314,10 +320,6 @@ function Get-EntraIdRoleAssignment {

# 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
$scopeIds = @()
$scopeIds += $roleAssignmentSchedules | Select-Object -ExpandProperty directoryScopeId
$scopeIds += $roleEligibilitySchedules | Select-Object -ExpandProperty directoryScopeId
$scopeIds = $scopeIds | Where-Object { $_.id -ne '/' } | Select-Object -Unique
$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.
Expand All @@ -327,11 +329,16 @@ function Get-EntraIdRoleAssignment {
$groupIds = $groupIds | Select-Object -Unique

# Prefetch group schedules for all groups used groups
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'

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.'
Expand All @@ -353,4 +360,4 @@ function Get-EntraIdRoleAssignment {

Write-Verbose "Total execution time: $($timer.Elapsed.TotalSeconds) seconds"
}
#endregion
#endregion MAIN
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Returns a collection of PSCustomObjects with properties including RoleId, RoleNa
![image](Images/Get-EntraIdRoleAssignment-Format-List.png)

**Notes:**
Requires Entra P2 license level. Ensure you have connected to Microsoft Graph with appropriate scopes (e.g., `Connect-MgGraph -Scopes 'RoleEligibilitySchedule.Read.Directory','PrivilegedEligibilitySchedule.Read.AzureADGroup', 'PrivilegedAssignmentSchedule.Read.AzureADGroup'`).
Requires Entra P2 license level. Ensure you have connected to Microsoft Graph with appropriate scopes (e.g., `Connect-MgGraph -Scopes 'RoleEligibilitySchedule.Read.Directory','RoleAssignmentSchedule.Read.Directory', 'PrivilegedEligibilitySchedule.Read.AzureADGroup', 'PrivilegedAssignmentSchedule.Read.AzureADGroup', 'LicenseAssignment.Read.All', 'AdministrativeUnit.Read.All', 'Application.Read.All'`).

### Get-EntraIdLevel

Expand Down