Scripts/Get-Roles.ps1

# Inspired by: https://ourcloudnetwork.com/export-all-admin-role-memberships-in-azure-ad-with-powershell/
Function Get-AllRoleActivity {
<#
    .SYNOPSIS
    Exports all directory role memberships with last login information.
 
    .DESCRIPTION
    Retrieves all directory roles, and exports a report of all role memberships with their last login activity.
 
    .PARAMETER OutputDir
    OutputDir is the parameter specifying the output directory.
    Default: Output\Roles
 
    .PARAMETER Encoding
    Encoding is the parameter specifying the encoding of the CSV output file.
    Default: UTF8
 
    .PARAMETER IncludeEmptyRoles
    When specified, includes roles with no members in the summary output.
    Default: False
 
    .PARAMETER LogLevel
    Specifies the level of logging:
    None: No logging
    Minimal: Critical errors only
    Standard: Normal operational logging
    Default: Standard
     
    .EXAMPLE
    Get-AllRoleActivity
    Exports all directory role memberships with last login information to the default output directory.
     
    .EXAMPLE
    Get-AllRoleActivity -OutputDir "C:\Reports"
    Exports directory role memberships to the specified directory.
         
    .EXAMPLE
    Get-AllRoleActivity -IncludeEmptyRoles
    Exports directory role memberships and also logs information about roles with no members.
     
    .EXAMPLE
    Get-AllRoleActivity -Encoding utf32
    Exports directory role memberships with UTF-32 encoding.
#>
    

    [CmdletBinding()]
    param(
        [string]$OutputDir = "Output\Roles",
        [string]$Encoding = "UTF8",
        [switch]$IncludeEmptyRoles = $false,
        [ValidateSet('None', 'Minimal', 'Standard')]
        [string]$LogLevel = 'Standard'
    )

    Set-LogLevel -Level ([LogLevel]::$LogLevel)
    $date = Get-Date -Format "yyyyMMddHHmm"

    Write-LogFile -Message "=== Starting Directory Role Membership Export ===" -Color "Cyan" -Level Standard

    $requiredScopes = @("User.Read.All", "Directory.Read.All", "AuditLog.Read.All")
    $graphAuth = Get-GraphAuthType -RequiredScopes $RequiredScopes
        
    if (!(Test-Path $OutputDir)) {
        try {
            New-Item -ItemType Directory -Force -Path $OutputDir > $null
        }
        catch {
            Write-LogFile -Message "[Error] Could not create output directory: $OutputDir" -Level Minimal
            return
        }
    }

    Write-LogFile -Message "[INFO] Retrieving directory roles and memberships..." -Level Standard
    
    $processedRoles = 0
    $rolesWithMembers = 0
    $rolesWithoutMembers = 0
    $totalMembers = 0
    $emptyRoles = @()
    $rolesWithUsers = @()
    $allRoleMembers = @()

    try {
        $allRoles = Get-MgDirectoryRole -All
        Write-LogFile -Message "[INFO] Found $($allRoles.Count) directory roles" -Level Standard
        
        foreach ($role in $allRoles) {
            $processedRoles++
            $displayName = $role.DisplayName
            $roleMembers = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id
            
            if ($null -eq $roleMembers -or $roleMembers.Count -eq 0) {
                $rolesWithoutMembers++
                $emptyRoles += $displayName
                continue
            }
            
            $rolesWithMembers++
            $roleMemberCount = 0
            
            foreach ($member in $roleMembers) {
                #Skip service principals
                if ($member.AdditionalProperties.'@odata.type' -match "servicePrincipal") {
                    Write-LogFile -Message "[INFO] Skipping service principal in role $displayName" -Level Standard
                    continue
                }
                
                $totalMembers++
                $userId = $member.Id
                $roleMemberCount++
                
                try {
                    $selectProperties = @(
                        "UserPrincipalName", "DisplayName", "Id", "Department", "JobTitle", 
                        "AccountEnabled", "CreatedDateTime", "SignInActivity"
                    )
                    
                    try {
                        $user = Get-MgUser -UserId $userId -Select $selectProperties -ErrorAction Stop
                    } catch {
                        if ($_.Exception.Response.StatusCode -eq 429) {
                            Start-Sleep -Seconds 5
                            $user = Get-MgUser -UserId $userId -Select $selectProperties -ErrorAction Stop
                        } else {
                            throw
                        }
                    }
                    
                    $userObject = [PSCustomObject]@{
                        Role = $displayName
                        UserName = $user.UserPrincipalName
                        UserId = $userId
                        DisplayName = $user.DisplayName
                        Department = $user.Department
                        JobTitle = $user.JobTitle
                        AccountEnabled = $user.AccountEnabled
                        CreatedDateTime = $user.CreatedDateTime
                        LastInteractiveSignIn = $user.SignInActivity.LastSignInDateTime
                        LastNonInteractiveSignIn = $user.SignInActivity.LastNonInteractiveSignInDateTime
                    }
                    
                    if ($user.SignInActivity.LastSignInDateTime) {
                        $daysSinceSignIn = (New-TimeSpan -Start $user.SignInActivity.LastSignInDateTime -End (Get-Date)).Days
                        $userObject | Add-Member -MemberType NoteProperty -Name "DaysSinceLastSignIn" -Value $daysSinceSignIn
                    } else {
                        $userObject | Add-Member -MemberType NoteProperty -Name "DaysSinceLastSignIn" -Value "No sign-in data"
                    }
                    
                    $allRoleMembers += $userObject
                }
                catch {
                    Write-LogFile -Message "[WARNING] Error processing user $userId in role $displayName`: $($_.Exception.Message)" -Color "Yellow" -Level Standard
                    
                    try {
                        $basicInfo = Get-MgUser -UserId $userId -Select "DisplayName,UserPrincipalName" -ErrorAction SilentlyContinue
                        $userName = $basicInfo.UserPrincipalName
                        $displayName = $basicInfo.DisplayName
                    }
                    catch {
                        $userName = "Unknown"
                        $displayName = "Unknown"
                    }
                    
                    $userObject = [PSCustomObject]@{
                        Role = $displayName
                        UserName = $userName
                        UserId = $userId
                        DisplayName = $displayName
                        Department = "Error retrieving data"
                        JobTitle = "Error retrieving data"
                        AccountEnabled = "Error retrieving data"
                        CreatedDateTime = "Error retrieving data"
                        LastInteractiveSignIn = "Error retrieving data"
                        LastNonInteractiveSignIn = "Error retrieving data"
                        DaysSinceLastSignIn = "Error retrieving data"
                    }
                    $allRoleMembers += $userObject
                }
            }
            
            $rolesWithUsers += "$displayName ($roleMemberCount users)"
        }

        $outputFile = "$OutputDir\$($date)-All-Roles.csv"
        $allRoleMembers | Export-Csv -Path $outputFile -NoTypeInformation -Encoding $Encoding

        Write-LogFile -Message "`nRoles with users:" -Color "Green" -Level Standard
        foreach ($role in $rolesWithUsers) {
            Write-LogFile -Message " + $role" -Level Standard
        }
        
        Write-LogFile -Message "`nEmpty roles:" -Color "Yellow" -Level Standard
        foreach ($emptyRole in $emptyRoles) {
            Write-LogFile -Message " - $emptyRole" -Level Standard
        }
        
        Write-LogFile -Message "`nSummary:" -Level Standard -Color "Cyan"
        Write-LogFile -Message " - Total roles processed: $processedRoles" -Level Standard
        Write-LogFile -Message " - Roles with members: $rolesWithMembers" -Level Standard
        Write-LogFile -Message " - Roles without members: $rolesWithoutMembers" -Level Standard
        Write-LogFile -Message " - Total role user assignments: $totalMembers" -Level Standard
        
        Write-LogFile -Message "`nExported file:" -Level Standard -Color "Cyan"
        Write-LogFile -Message " - File: $outputFile" -Level Standard
    }
    catch {
        Write-LogFile -Message "[ERROR] An error occurred: $($_.Exception.Message)" -Color "Red" -Level Minimal
        throw
    }
}

function Get-PIMAssignments {
#Inspired by: https://github.com/nathanmcnulty/nathanmcnulty/blob/master/Entra/FindSyncedPrivilegedUsers-NoPIM.ps1 & https://github.com/nathanmcnulty/nathanmcnulty/blob/master/Entra/FindSyncedPrivilegedUsers-PIM.ps1
<#
    .SYNOPSIS
    Generates an overview of all Entra ID PIM role assignments.
 
    .DESCRIPTION
    Retrieves all Privileged Identity Management (PIM) role assignments in Entra ID. It includes both active and eligible assignments and expands group memberships to show individual users.
 
    .PARAMETER OutputDir
    OutputDir is the parameter specifying the output directory.
    Default: Output\Roles
 
    .PARAMETER Encoding
    Encoding is the parameter specifying the encoding of the CSV output file.
    Default: UTF8
 
    .PARAMETER LogLevel
    Specifies the level of logging:
    None: No logging
    Minimal: Critical errors only
    Standard: Normal operational logging
    Default: Standard
 
    .EXAMPLE
    Get-PIMAssignments
    Exports all PIM role assignments to the default output directory.
     
    .EXAMPLE
    Get-PIMAssignments -OutputDir "C:\Reports"
    Exports PIM role assignments to the specified directory.
     
    .EXAMPLE
    Get-PIMAssignments -LogLevel Minimal
    Exports PIM role assignments with minimal logging.
#>


    [CmdletBinding()]
    param(
        [string]$OutputDir = "Output\Roles",
        [string]$Encoding = "UTF8",
        [ValidateSet('None', 'Minimal', 'Standard')]
        [string]$LogLevel = 'Standard'
    )

    Set-LogLevel -Level ([LogLevel]::$LogLevel)
    $date = Get-Date -Format "yyyyMMddHHmm"

    Write-LogFile -Message "=== Starting PIM Role Assignment Export ===" -Color "Cyan" -Level Standard

    $requiredScopes = @("RoleAssignmentSchedule.Read.Directory", "RoleEligibilitySchedule.Read.Directory", "User.Read.All", "Group.Read.All")
    $graphAuth = Get-GraphAuthType -RequiredScopes $RequiredScopes
        
    if (!(Test-Path $OutputDir)) {
        try {
            New-Item -ItemType Directory -Force -Path $OutputDir > $null
        }
        catch {
            Write-LogFile -Message "[Error] Could not create output directory: $OutputDir" -Level Minimal
            return
        }
    }

    Write-LogFile -Message "[INFO] Retrieving PIM role assignments..." -Level Standard
    $allAssignments = @()
    $processedActiveAssignments = 0
    $processedEligibleAssignments = 0
    $skippedAssignments = 0
    
    try {
        Write-LogFile -Message "[INFO] Retrieving active PIM assignments..." -Color "Green" -Level Standard
        $activeAssignmentsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignmentSchedules?`$expand=principal,roleDefinition"
        $activeResponse = Invoke-MgGraphRequest -Method GET -Uri $activeAssignmentsUri
        $activePimAssignments = $activeResponse.value

        $nextLink = $activeResponse.'@odata.nextLink'
        while ($null -ne $nextLink) {
            $activeResponse = Invoke-MgGraphRequest -Method GET -Uri $nextLink
            $activePimAssignments += $activeResponse.value
            $nextLink = $activeResponse.'@odata.nextLink'
        }
        
        $activeAssignmentsCount = $activePimAssignments.Count
        Write-LogFile -Message "[INFO] Found $($activeAssignmentsCount) active PIM assignments" -Level Standard
        
        foreach ($assignment in $activePimAssignments) {
            $added = $false
            if ($assignment.principal.'@odata.type' -match '.user') {
                $user = $assignment.Principal
                $isOnPremSynced = $user.onPremisesSyncEnabled -eq $true
                                
                $allAssignments += [PSCustomObject]@{
                    RoleName = $assignment.roleDefinition.displayName
                    UserPrincipalName = $user.userPrincipalName
                    DisplayName = $user.displayName
                    AssignmentType = "PIM Active"
                    SourceType = "Direct"
                    SourceName = "N/A"
                    OnPremisesSynced = $isOnPremSynced
                    AssignmentStatus = "Active"
                    StartDateTime = $assignment.scheduleInfo.startDateTime
                    EndDateTime = if ($assignment.scheduleInfo.expiration) { $assignment.scheduleInfo.expiration.endDateTime } else { "Permanent" }
                    DirectoryScopeId = $assignment.directoryScopeId
                }
                $processedActiveAssignments++
                $added = $true
            }

            elseif ($assignment.principal.'@odata.type' -match '.group') {
                $roleName = $assignment.roleDefinition.displayName
                $groupId = $assignment.principalId
                $groupName = $assignment.principal.displayName
                
                Write-LogFile -Message "[INFO] Processing group $groupName with role $roleName" -Level Standard
                
                try {
                    $groupMembersUri = "https://graph.microsoft.com/v1.0/groups/$groupId/members"
                    $groupResponse = Invoke-MgGraphRequest -Method GET -Uri $groupMembersUri
                    $groupMembers = $groupResponse.value
                    
                    $nextLink = $groupResponse.'@odata.nextLink'
                    while ($null -ne $nextLink) {
                        $groupResponse = Invoke-MgGraphRequest -Method GET -Uri $nextLink
                        $groupMembers += $groupResponse.value
                        $nextLink = $groupResponse.'@odata.nextLink'
                    }

                    $groupMemberCount = 0
                    foreach ($member in $groupMembers) {
                        if ($member.'@odata.type' -notmatch '.user') {
                            continue
                        }
                        
                        try {
                            $userId = $member.id
                            $userUri = "https://graph.microsoft.com/v1.0/users/$userId"
                            $userDetails = Invoke-MgGraphRequest -Method GET -Uri $userUri
                            $isOnPremSynced = $userDetails.onPremisesSyncEnabled -eq $true
                            
                            $allAssignments += [PSCustomObject]@{
                                RoleName = $roleName
                                UserPrincipalName = $userDetails.userPrincipalName
                                DisplayName = $userDetails.displayName
                                AssignmentType = "PIM Active"
                                SourceType = "Group"
                                SourceName = $groupName
                                OnPremisesSynced = $isOnPremSynced
                                AssignmentStatus = "Active"
                                StartDateTime = $assignment.scheduleInfo.startDateTime
                                EndDateTime = if ($assignment.scheduleInfo.expiration) { $assignment.scheduleInfo.expiration.endDateTime } else { "Permanent" }
                                DirectoryScopeId = $assignment.directoryScopeId
                            }
                            $processedActiveAssignments++
                            $groupMemberCount++
                            $added = $true
                        }
                        catch {
                            Write-LogFile -Message "[WARNING] Could not process user $userId in group $groupName`: $_" -Color "Yellow" -Level Standard
                            $skippedAssignments++
                        }
                    }
                    if ($groupMemberCount -eq 0) {
                        $skippedAssignments++
                    }
                }
                catch {
                    Write-LogFile -Message "[WARNING] Error processing group members for $groupName`: $_" -Color "Yellow" -Level Standard
                    $skippedAssignments++
                }
            }
            if (-not $added) {
                $skippedAssignments++
            }
        }
        
        Write-LogFile -Message "[INFO] Retrieving eligible PIM assignments..." -Color "Green" -Level Standard
        $eligibleAssignmentsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?`$expand=principal,roleDefinition"
        $eligibleResponse = Invoke-MgGraphRequest -Method GET -Uri $eligibleAssignmentsUri
        $eligiblePimAssignments = $eligibleResponse.value
        
        $nextLink = $eligibleResponse.'@odata.nextLink'
        while ($null -ne $nextLink) {
            $eligibleResponse = Invoke-MgGraphRequest -Method GET -Uri $nextLink
            $eligiblePimAssignments += $eligibleResponse.value
            $nextLink = $eligibleResponse.'@odata.nextLink'
        }
        
        $eligibleAssignmentsCount = $eligiblePimAssignments.Count
        Write-LogFile -Message "[INFO] Found $($eligiblePimAssignments.Count) eligible PIM assignments" -Level Standard

        foreach ($assignment in $eligiblePimAssignments) {
            $added = $false
            if ($assignment.principal.'@odata.type' -match '.user') {
                $user = $assignment.principal
                $isOnPremSynced = $user.onPremisesSyncEnabled -eq $true
                
                $allAssignments += [PSCustomObject]@{
                    RoleName = $assignment.roleDefinition.displayName
                    UserPrincipalName = $user.userPrincipalName
                    DisplayName = $user.displayName
                    AssignmentType = "PIM Eligible"
                    SourceType = "Direct"
                    SourceName = "N/A"
                    OnPremisesSynced = $isOnPremSynced
                    AssignmentStatus = "Eligible"
                    StartDateTime = $assignment.scheduleInfo.startDateTime
                    EndDateTime = if ($assignment.scheduleInfo.expiration) { $assignment.scheduleInfo.expiration.endDateTime } else { "Permanent" }
                    DirectoryScopeId = $assignment.directoryScopeId
                }
                $processedEligibleAssignments++
                $added = $true
            }

            elseif ($assignment.principal.'@odata.type' -match '.group') {
                $roleName = $assignment.roleDefinition.displayName
                $groupId = $assignment.principalId
                $groupName = $assignment.principal.displayName
                
                Write-LogFile -Message "[INFO] Processing group $groupName with role $roleName" -Level Standard
                
                try {
                    $groupMembersUri = "https://graph.microsoft.com/v1.0/groups/$groupId/members"
                    $groupResponse = Invoke-MgGraphRequest -Method GET -Uri $groupMembersUri
                    $groupMembers = $groupResponse.value
                    
                    $nextLink = $groupResponse.'@odata.nextLink'
                    while ($null -ne $nextLink) {
                        $groupResponse = Invoke-MgGraphRequest -Method GET -Uri $nextLink
                        $groupMembers += $groupResponse.value
                        $nextLink = $groupResponse.'@odata.nextLink'
                    }
                    
                    $groupMemberCount = 0
                    foreach ($member in $groupMembers) {
                        if ($member.'@odata.type' -notmatch '.user') {
                            continue
                        }
                        
                        try {
                            $userId = $member.id
                            $userUri = "https://graph.microsoft.com/v1.0/users/$userId"
                            $userDetails = Invoke-MgGraphRequest -Method GET -Uri $userUri
                            $isOnPremSynced = $userDetails.onPremisesSyncEnabled -eq $true
                            
                            $allAssignments += [PSCustomObject]@{
                                RoleName = $roleName
                                UserPrincipalName = $userDetails.userPrincipalName
                                DisplayName = $userDetails.displayName
                                AssignmentType = "PIM Eligible"
                                SourceType = "Group"
                                SourceName = $groupName
                                OnPremisesSynced = $isOnPremSynced
                                AssignmentStatus = "Eligible"
                                StartDateTime = $assignment.scheduleInfo.startDateTime
                                EndDateTime = if ($assignment.scheduleInfo.expiration) { $assignment.scheduleInfo.expiration.endDateTime } else { "Permanent" }
                                DirectoryScopeId = $assignment.directoryScopeId
                            }
                            $processedEligibleAssignments++
                            $groupMemberCount++
                            $added = $true
                        }
                        catch {
                            Write-LogFile -Message "[WARNING] Could not process user $userId in group $groupName`: $_" -Color "Yellow" -Level Standard
                            $skippedAssignments++

                        }
                    }
                    if ($groupMemberCount -eq 0) {
                        $skippedAssignments++
                    }
                }
                catch {
                    Write-LogFile -Message "[WARNING] Error processing group members for $groupName`: $_" -Color "Yellow" -Level Standard
                    $skippedAssignments++
                }
            }
            if (-not $added) {
                $skippedAssignments++
            }
        }
        
        $outputFile = "$OutputDir\$($date)-PIM-Assignments.csv"
        $allAssignments | Export-Csv -Path $outputFile -NoTypeInformation -Encoding $Encoding
        
        $totalAssignments = $allAssignments.Count
        $pimActiveCount = ($allAssignments | Where-Object { $_.AssignmentStatus -eq "Active" }).Count
        $pimEligibleCount = ($allAssignments | Where-Object { $_.AssignmentStatus -eq "Eligible" }).Count
        $directCount = ($allAssignments | Where-Object { $_.SourceType -eq "Direct" }).Count
        $groupCount = ($allAssignments | Where-Object { $_.SourceType -eq "Group" }).Count
        $onPremSyncedCount = ($allAssignments | Where-Object { $_.OnPremisesSynced -eq $true }).Count
        $cloudOnlyCount = ($allAssignments | Where-Object { $_.OnPremisesSynced -eq $false }).Count
        
        Write-LogFile -Message "`nSummary:" -Level Standard -Color "Cyan"
        Write-LogFile -Message " - Total role assignments: $totalAssignments" -Level Standard
        Write-LogFile -Message " - PIM Active assignments: $pimActiveCount" -Level Standard
        Write-LogFile -Message " - PIM Eligible assignments: $pimEligibleCount" -Level Standard
        Write-LogFile -Message " - Direct assignments: $directCount" -Level Standard
        Write-LogFile -Message " - Group-inherited assignments: $groupCount" -Level Standard
        Write-LogFile -Message " - On-premises synced users: $onPremSyncedCount" -Level Standard
        Write-LogFile -Message " - Cloud-only users: $cloudOnlyCount" -Level Standard

        # Only show this if there's a discrepancy between found and processed
        if (($activeAssignmentsCount + $eligibleAssignmentsCount) -ne $totalAssignments) {
            Write-LogFile -Message "`nNote: $($activeAssignmentsCount + $eligibleAssignmentsCount) total assignments were found, but only $totalAssignments were processed." -Level Standard -Color "Yellow"
            Write-LogFile -Message " - This is usually due to service principals or empty groups that were skipped during processing." -Level Standard
        }
        
        Write-LogFile -Message "`nExported file:" -Level Standard -Color "Cyan"
        Write-LogFile -Message " - File: $outputFile" -Level Standard
    }
    catch {
        Write-LogFile -Message "[ERROR] An error occurred: $($_.Exception.Message)" -Color "Red" -Level Minimal
        throw
    }
}