diff --git a/EntraReporter/EntraReporter.psd1 b/EntraReporter/EntraReporter.psd1 index 43c90d3..28a27af 100644 --- a/EntraReporter/EntraReporter.psd1 +++ b/EntraReporter/EntraReporter.psd1 @@ -55,7 +55,9 @@ # TypesToProcess = @() # Format files (.ps1xml) to be loaded when importing this module - # FormatsToProcess = @() + FormatsToProcess = @( + 'internal\formats\EntraReporter.RoleAssignment.Format.ps1xml' + ) # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # NestedModules = @() diff --git a/EntraReporter/functions/Get-EntraIdLevel.ps1 b/EntraReporter/functions/Get-EntraIdLevel.ps1 new file mode 100644 index 0000000..df7a618 --- /dev/null +++ b/EntraReporter/functions/Get-EntraIdLevel.ps1 @@ -0,0 +1,113 @@ +<# + .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 +} + diff --git a/EntraReporter/functions/Get-EntraIdRoleAssignment.ps1 b/EntraReporter/functions/Get-EntraIdRoleAssignment.ps1 new file mode 100644 index 0000000..b530e7a --- /dev/null +++ b/EntraReporter/functions/Get-EntraIdRoleAssignment.ps1 @@ -0,0 +1,352 @@ +<# + .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()] + param( + [Parameter()] + [string[]] + $RoleName + ) + + #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 { + [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 ) { + 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 } | Select-Object -ExpandProperty 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. + $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 } + } + 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 } + } + 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 | 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 | Select-Object -ExpandProperty 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 | Select-Object -ExpandProperty 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 + $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. + $groupIds = @() + $groupIds += $roleAssignmentSchedules | Select-Object -ExpandProperty principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' } | Select-Object -ExpandProperty Id + $groupIds += $roleEligibilitySchedules | Select-Object -ExpandProperty principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' } | Select-Object -ExpandProperty Id + $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 (($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 \ No newline at end of file diff --git a/EntraReporter/functions/readme.md b/EntraReporter/functions/readme.md deleted file mode 100644 index 105038b..0000000 --- a/EntraReporter/functions/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -# Functions - -Folder for all the functions the user is supposed to have access to. diff --git a/EntraReporter/internal/formats/EntraReporter.RoleAssignment.Format.ps1xml b/EntraReporter/internal/formats/EntraReporter.RoleAssignment.Format.ps1xml new file mode 100644 index 0000000..dc74d37 --- /dev/null +++ b/EntraReporter/internal/formats/EntraReporter.RoleAssignment.Format.ps1xml @@ -0,0 +1,66 @@ + + + + + Role Assignment Table Default + + EntraReporter.RoleAssignment + + + + + 25 + + + 25 + + + 20 + + + 14 + + + 9 + + + + + 10 + + + + 10 + + + + + + + RoleName + + + UserPrincipalName + + + UserDisplayName + + + Scope + + + EffectiveState + + + $($_.EffectiveStartTime.ToShortDateString()) + + + $($_.EffectiveEndTime.ToShortDateString()) + + + + + + + + \ No newline at end of file diff --git a/EntraReporter/internal/functions/Get-AdministrativeUnit.ps1 b/EntraReporter/internal/functions/Get-AdministrativeUnit.ps1 new file mode 100644 index 0000000..50968ea --- /dev/null +++ b/EntraReporter/internal/functions/Get-AdministrativeUnit.ps1 @@ -0,0 +1,21 @@ +# Generate a list of all administrative units in the directory for resolving scope information in role assignments. +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 | Select-Object -ExpandProperty value + foreach ($adminUnit in $allAdministrativeUnits) { + $results += [pscustomobject] @{ + directoryScopeId = "/administrativeUnits/$($adminUnit.id)" + displayName = $adminUnit.displayName + } + } + $results += [pscustomobject] @{ + directoryScopeId = '/' + displayName = 'Directory' + } + + return $results +} \ No newline at end of file diff --git a/EntraReporter/internal/functions/Get-EntraIdGroupScheduleBatch.ps1 b/EntraReporter/internal/functions/Get-EntraIdGroupScheduleBatch.ps1 new file mode 100644 index 0000000..35466da --- /dev/null +++ b/EntraReporter/internal/functions/Get-EntraIdGroupScheduleBatch.ps1 @@ -0,0 +1,81 @@ +<# + .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 { + 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-ArrayIntoChunks -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 } +} + diff --git a/EntraReporter/internal/functions/Invoke-GraphBatch.ps1 b/EntraReporter/internal/functions/Invoke-GraphBatch.ps1 new file mode 100644 index 0000000..1fd9238 --- /dev/null +++ b/EntraReporter/internal/functions/Invoke-GraphBatch.ps1 @@ -0,0 +1,28 @@ +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 +} + diff --git a/EntraReporter/internal/functions/Invoke-GraphPaged.ps1 b/EntraReporter/internal/functions/Invoke-GraphPaged.ps1 new file mode 100644 index 0000000..635e834 --- /dev/null +++ b/EntraReporter/internal/functions/Invoke-GraphPaged.ps1 @@ -0,0 +1,15 @@ +# Robust pagination helper +function Invoke-GraphPaged { + 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 +} + diff --git a/EntraReporter/internal/functions/Split-ArrayIntoChunks.ps1 b/EntraReporter/internal/functions/Split-ArrayIntoChunks.ps1 new file mode 100644 index 0000000..677fc84 --- /dev/null +++ b/EntraReporter/internal/functions/Split-ArrayIntoChunks.ps1 @@ -0,0 +1,25 @@ +function Split-ArrayIntoChunks { + [CmdletBinding()] + 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 +} diff --git a/EntraReporter/internal/functions/Test-GraphConnection.ps1 b/EntraReporter/internal/functions/Test-GraphConnection.ps1 new file mode 100644 index 0000000..6bb9c3b --- /dev/null +++ b/EntraReporter/internal/functions/Test-GraphConnection.ps1 @@ -0,0 +1,28 @@ +<# + .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 +#> +function Test-GraphConnection { + [CmdletBinding()] + 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 +} \ No newline at end of file diff --git a/EntraReporter/internal/functions/readme.md b/EntraReporter/internal/functions/readme.md deleted file mode 100644 index 0643487..0000000 --- a/EntraReporter/internal/functions/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -# Internal > Functions - -Folder for all the functions you want the user to not see. diff --git a/Images/Get-EntraIdRoleAssignment-Format-List.png b/Images/Get-EntraIdRoleAssignment-Format-List.png new file mode 100644 index 0000000..0f0ff21 Binary files /dev/null and b/Images/Get-EntraIdRoleAssignment-Format-List.png differ diff --git a/Images/Get-EntraIdRoleAssignment.png b/Images/Get-EntraIdRoleAssignment.png new file mode 100644 index 0000000..8666ce5 Binary files /dev/null and b/Images/Get-EntraIdRoleAssignment.png differ diff --git a/README.md b/README.md index 029ce55..27148c7 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,99 @@ # EntraReporter -ADD DESCRIPTION HERE +EntraReporter is a PowerShell module designed to report on information from Entra ID (Azure Active Directory), including role assignments and eligibility schedules. It leverages Microsoft Graph API to provide detailed insights into privileged access management (PAM) configurations, including role assignments for users, groups, and service principals. The module is particularly useful for compliance auditing, security reviews, and administrative reporting. -## Project Setup +Key features include: +- Comprehensive role assignment reporting with effective date ranges, with support for group-based role assignments and eligibility +- License level detection for Entra ID tiers +- Batch processing for efficient API calls +- Detailed output with customizable formatting -> TODO: Delete this section from the readme, once you are done with it +## Installation -> Setup step 1: Configuring the project +To install the module, run: -In the root folder - right beside this readme file - you can find a `config.psd1` file. -You can find and adjust settings there, such as whether you want the version automatically updated or the `FunctionsToExport` be auto-generated. +```powershell +Install-Module -Name 'EntraReporter' -Scope CurrentUser +``` -Each setting has a description, explaining what it does. -If this is your first module project, you may want to enable `ExportFunction`, to have one less thing to deal with. +Alternatively, clone the repository and import the module manually: -> What you need to know & update +```powershell +git clone https://github.com/kovergard/EntraReporter.git +cd EntraReporter +Import-Module .\EntraReporter\EntraReporter.psd1 +``` -Essentially, your module is ready to roll, just needing your content, so these are the things you need to update: +## Functions -```text -readme.md -EntraReporter\EntraReporter.psd1 -EntraReporter\functions -EntraReporter\internal\functions -EntraReporter\internal\scripts -``` +### Get-EntraIdRoleAssignment -+ `readme.md`: Add some description and examples on how to use your project, then delete the "Project Setup" section, which is for you only. -+ `EntraReporter.psd1`: The module manifest. You may need to register your public functions here, maintain the version number or declare dependencies your module uses. -+ `functions`: The folder your public functions go. That is, functions your users should have access to. One function per file, file should have the same name as the function. -+ `internal\functions`: The folder where your internal functions should be placed, that users should not directly use. One function per file, file should have the same name as the function. -+ `internal\scripts`: The folder where scripts go, that are run on module import only. Use for declaring module-wide variables, do some cleanup, or whatever else needs to happen only once per session. +**Description:** +This cmdlet queries Entra ID role assignments and eligibility information for users, groups, and service principals. The function queries Microsoft Graph Privileged Identity Management (PIM) APIs to consolidate role schedules into a unified report, resolving group memberships and calculating effective assignment windows. -## Installation +**Parameters:** +- `RoleName` (optional): Filter by one or more role names (e.g., 'Global Administrator', 'User Administrator'). -To install the module, run: +**Output:** +Returns a collection of PSCustomObjects with properties including RoleId, RoleName, PrincipalId, AssignmentType, Scope, EffectiveState, EffectiveStartTime, EffectiveEndTime, and more. -```powershell -Install-Module -Name 'EntraReporter' -Scope CurrentUser -``` +**Examples:** -Alternatively, if you have any trouble getting modules installed, this might work instead: +1. **Retrieve all role assignments and eligibilities:** + ```powershell + Get-EntraIdRoleAssignment + ``` + This command fetches all role assignments in the tenant, and shows the most important properties. + ![image](Images/Get-EntraIdRoleAssignment.png) +2. **Filter by specific role with all details:** + ```powershell + Get-EntraIdRoleAssignment -RoleName "User Administrator" | Format-List + ``` + This retrieves assignments only for the 'User Administrator' role, and show all returned properties. + ![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'`). + +### Get-EntraIdLevel + +**Description:** +Determines the Entra ID license level (Free, P1, or P2) for the connected tenant by querying subscribed SKUs via Microsoft Graph. + +**Parameters:** +- `IncludeDetails` (switch): Includes detailed license counts and SKU part numbers. + +**Output:** +PSCustomObject with Level, IsP1Available, IsP2Available, and optionally detailed counts. + +**Example:** ```powershell -Invoke-WebRequest 'https://raw.githubusercontent.com/PowershellFrameworkCollective/PSFramework.NuGet/refs/heads/master/bootstrap.ps1' -UseBasicParsing | Invoke-Expression -Install-PSFModule -Name 'EntraReporter' +Get-EntraIdLevel -IncludeDetails ``` +Output example: +``` +Level : P2 +IsP1Available : True +IsP2Available : True +P1EnabledCount: 50 +P2EnabledCount: 25 +P1AssignedCount: 45 +P2AssignedCount: 20 +P1SkuPartNumbers: {AAD_PREMIUM} +P2SkuPartNumbers: {AAD_PREMIUM_P2} +``` + +### Other Functions + +- **Get-EntraIdGroupScheduleBatch:** Retrieves group membership schedules in batches for efficiency. +- **Get-AdministrativeUnit:** Internal function for resolving administrative unit names. +- **Invoke-GraphBatch / Invoke-GraphPaged:** Internal utilities for Graph API calls. + +## Contributing + +Contributions are welcome! Please submit issues and pull requests on GitHub. -## Profit +## License -ADD EXAMPLES HERE +This project is licensed under the MIT License - see the LICENSE file for details.