Sunday, November 15, 2026

Export a User's Manager Chain and Peers to CSV with Microsoft Graph PowerShell

Ever needed to answer “Who is my manager’s manager, all the way to the top?” or “Who else reports to my manager?” for an audit, an approval workflow, or an org-chart refresh? Entra ID already stores the manager relationship for every user. In this post we build one read-only PowerShell script that uses Microsoft Graph to export both answers to CSV.

What the script produces

1. Management chain – start from one user and walk upward until someone has no manager.

Level 4 · TopNestor WilkeChief Executive OfficerLevel 3Isaiah LangerVP TechnologyLevel 2Lynne RobbinsDirector, ITLevel 1Megan BowenIT ManagerLevel 0 · InputAlex WilberSystems EngineerScript walksupward usingGet-MgUserManageruntil no manager

2. Peers – everyone else who reports to the same manager.

Megan BowenIT ManagerAlex WilberInput userDiego SicilianiPeerGrady ArchiePeerJohanna LorenzPeerGet-MgUserDirectReport on the manager → everyone except the input user = peers

All names above are fictional sample users on the contoso.com demo domain.

How it works

1. ConnectUser.Read.All2. Find userGet-MgUser3. Walk upGet-MgUserManager4. Find peersDirectReport5. Export2 CSV files

Prerequisites

ItemDetails
PowerShellWindows PowerShell 5.1 or PowerShell 7+
ModuleInstall-Module Microsoft.Graph.Users -Scope CurrentUser
PermissionUser.Read.All (delegated or application) – least-privileged for manager and direct reports
ConsentFirst sign-in may show a consent prompt; some tenants require admin consent

Is it safe? Yes – it is read-only

CmdletPurposeChanges tenant?
Connect-MgGraphSign in with User.Read.AllNo
Get-MgUserRead user propertiesNo
Get-MgUserManagerRead manager linkNo
Get-MgUserDirectReportRead direct reportsNo
Export-CsvWrite CSV on your local PCNo (local file only)

How to run

# One user
.\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com"

# Several users, custom output folder
.\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com","diego.siciliani@contoso.com" -OutputFolder C:\Temp

# Leave disabled accounts out of the peer list
.\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com" -ExcludeDisabled

Sample output

ManagerChain_<timestamp>.csv

InputUserLevelRelationshipDisplayNameJobTitleStatusManagerUPN
alex.wilber@contoso.com0UserAlex WilberSystems EngineerActivemegan.bowen@contoso.com
alex.wilber@contoso.com1Manager L1Megan BowenIT ManagerActivelynne.robbins@contoso.com
alex.wilber@contoso.com2Manager L2Lynne RobbinsDirector, ITActiveisaiah.langer@contoso.com
alex.wilber@contoso.com3Manager L3Isaiah LangerVP TechnologyActivenestor.wilke@contoso.com
alex.wilber@contoso.com4Manager L4Nestor WilkeChief Executive OfficerActive

Peers_<timestamp>.csv

InputUserRelationshipDisplayNameJobTitleStatusManagerUPN
alex.wilber@contoso.comPeerDiego SicilianiNetwork EngineerActivemegan.bowen@contoso.com
alex.wilber@contoso.comPeerGrady ArchieCloud EngineerDisabledmegan.bowen@contoso.com
alex.wilber@contoso.comPeerJohanna LorenzSupport AnalystActivemegan.bowen@contoso.com

With -ExcludeDisabled, the disabled peer (Grady Archie) is left out of the peer list.

Code explanation (section by section)

The script is split into numbered sections. Each section in the full script below carries the same number in its comment header.

#SectionWhat it does
1ParametersDeclares the inputs. [string[]] lets you pass one or many addresses. MaxDepth stops the upward walk if directory data is ever broken. -ExcludeDisabled leaves disabled accounts out of the peer list.
2Connect to Microsoft GraphLoads the Microsoft Graph Users module and signs in with User.Read.All – the least-privileged permission that can read the manager and direct reports of other users. An existing session is reused.
3Settings and cacheRequests only the properties we need with -Property and keeps a small in-memory cache, so a manager shared by many people is fetched only once.
4Helper: Get-UserDetailA tiny wrapper around Get-MgUser that checks the cache first. It stores the object under both its Id and the UPN you passed in.
5Helper: Get-ManagerIdCalls Get-MgUserManager. When a user has no manager, Graph returns a 404 Request_ResourceNotFound error; the script treats that as “top of the org” and returns $null. Any other error is re-thrown.
6Helper: New-RowBuilds one consistent row object so both CSV files have the same columns, including a Status column (Active / Disabled) based on AccountEnabled.
7Main loop – process each input userThe main loop. 7a finds the input user. 7b walks upward one level at a time, recording each person and stopping at the top, on a loop, or at MaxDepth. It warns when a manager in the chain is disabled. 7c asks the manager for all direct reports, keeps only user objects (direct reports can also be contacts), and skips the input user – the rest are peers. Disabled peers are skipped when -ExcludeDisabled is used.
8Export to CSVExports both result lists with Export-Csv. A timestamp in the file name means earlier runs are never overwritten.
9Summary on screenPrints the file paths and a quick table on screen so you can check results without opening Excel.

Complete script

Save as Get-OrgHierarchy.ps1. Every section is commented, so the script explains itself.

<#
.SYNOPSIS
    Exports a user's full management chain (user -> manager -> ... -> top) and the
    list of peers (everyone else who reports to the same manager) to CSV.

.DESCRIPTION
    READ-ONLY script. It only reads data from Microsoft Graph.
    The only thing it writes is CSV files on your local machine.

.EXAMPLE
    .\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com"

.EXAMPLE
    .\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com","megan.bowen@contoso.com" -OutputFolder C:\Temp

.EXAMPLE
    .\Get-OrgHierarchy.ps1 -UserPrincipalName "alex.wilber@contoso.com" -ExcludeDisabled

.NOTES
    Module     : Microsoft.Graph.Users
    Permission : User.Read.All (delegated or application)
#>

# ---------------------------------------------------------------------------
# 1. PARAMETERS
#    -UserPrincipalName : one or more emails/UPNs to process
#    -OutputFolder      : where the CSV files are saved (default = current folder)
#    -MaxDepth          : safety limit so the upward walk can never run forever
#    -ExcludeDisabled   : leave disabled (blocked sign-in) accounts out of the peer list
# ---------------------------------------------------------------------------
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string[]]$UserPrincipalName,

    [string]$OutputFolder = (Get-Location).Path,

    [ValidateRange(1, 100)]
    [int]$MaxDepth = 20,

    [switch]$ExcludeDisabled
)

# ---------------------------------------------------------------------------
# 2. CONNECT TO MICROSOFT GRAPH
#    Load the Users module and sign in with read-only permission.
#    If a Graph session already exists, it is reused.
# ---------------------------------------------------------------------------
Import-Module Microsoft.Graph.Users -ErrorAction Stop

if (-not (Get-MgContext)) {
    Connect-MgGraph -Scopes 'User.Read.All' -NoWelcome
}

# ---------------------------------------------------------------------------
# 3. SETTINGS AND CACHE
#    $userProps : only the properties we need (keeps Graph calls light)
#    $userCache : stores users already fetched, so a shared manager
#                 is looked up only once even across many input users
# ---------------------------------------------------------------------------
$userProps = 'id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,accountEnabled'
$userCache = @{}

# ---------------------------------------------------------------------------
# 4. HELPER: Get-UserDetail
#    Returns a user object by Id or UPN, using the cache when possible.
# ---------------------------------------------------------------------------
function Get-UserDetail {
    param([string]$Id)

    if ($userCache.ContainsKey($Id)) {
        return $userCache[$Id]
    }
    $u = Get-MgUser -UserId $Id -Property $userProps -ErrorAction Stop
    $userCache[$u.Id] = $u
    $userCache[$Id]   = $u
    return $u
}

# ---------------------------------------------------------------------------
# 5. HELPER: Get-ManagerId
#    Returns the manager's Id, or $null when no manager is set.
#    Graph returns "404 / Request_ResourceNotFound" when a user has no
#    manager - that simply means we reached the top of the chain.
# ---------------------------------------------------------------------------
function Get-ManagerId {
    param([string]$Id)

    try {
        return (Get-MgUserManager -UserId $Id -ErrorAction Stop).Id
    } catch {
        if ($_.Exception.Message -match 'Request_ResourceNotFound|NotFound|does not exist') {
            return $null
        } else {
            throw
        }
    }
}

# ---------------------------------------------------------------------------
# 6. HELPER: New-Row
#    Builds one tidy CSV row so both outputs share the same columns.
#    Status turns AccountEnabled (True/False) into Active / Disabled.
# ---------------------------------------------------------------------------
function New-Row {
    param($InputUser, $Level, $Relationship, $User, $ManagerUpn)

    [pscustomobject]@{
        InputUser         = $InputUser
        Level             = $Level
        Relationship      = $Relationship
        DisplayName       = $User.DisplayName
        UserPrincipalName = $User.UserPrincipalName
        Mail              = $User.Mail
        JobTitle          = $User.JobTitle
        Department        = $User.Department
        OfficeLocation    = $User.OfficeLocation
        AccountEnabled    = $User.AccountEnabled
        Status            = if ($User.AccountEnabled) { 'Active' } else { 'Disabled' }
        ManagerUPN        = $ManagerUpn
    }
}

# Result collections for the two CSV files
$chainRows = [System.Collections.Generic.List[object]]::new()
$peerRows  = [System.Collections.Generic.List[object]]::new()

# ---------------------------------------------------------------------------
# 7. MAIN LOOP - process each input user
# ---------------------------------------------------------------------------
foreach ($upn in $UserPrincipalName) {

    Write-Host "Processing $upn ..." -ForegroundColor Cyan

    # 7a. Find the starting user; skip if the address does not exist
    try {
        $current = Get-UserDetail -Id $upn
    } catch {
        Write-Warning "User not found: $upn"
        continue
    }
    $startUser = $current

    # 7b. WALK UP THE CHAIN
    #     Level 0 = the user, Level 1 = manager, Level 2 = manager's manager ...
    #     $visited guards against loops (A -> B -> A) caused by bad directory data.
    $visited = [System.Collections.Generic.HashSet[string]]::new()
    $level   = 0

    while ($current -and $level -le $MaxDepth) {

        [void]$visited.Add($current.Id)

        $managerId = Get-ManagerId -Id $current.Id
        $manager   = $null
        if ($managerId) {
            $manager = Get-UserDetail -Id $managerId
        }

        $relationship = if ($level -eq 0) { 'User' } else { "Manager L$level" }
        $chainRows.Add((New-Row $upn $level $relationship $current $manager.UserPrincipalName))

        # Warn when a manager in the chain is disabled - usually a stale manager attribute
        if ($level -gt 0 -and -not $current.AccountEnabled) {
            Write-Warning "$upn : manager at level $level ($($current.UserPrincipalName)) is DISABLED"
        }

        if (-not $manager) {
            break                                   # top of the org reached
        }
        if ($visited.Contains($manager.Id)) {
            Write-Warning "Loop detected at $($manager.UserPrincipalName)"
            break
        }

        $current = $manager
        $level++
    }

    # 7c. FIND PEERS (everyone else reporting to the same manager)
    #     Direct reports can include contacts, so keep only user objects.
    $managerId = Get-ManagerId -Id $startUser.Id
    if ($managerId) {
        $manager = Get-UserDetail -Id $managerId

        $reports = Get-MgUserDirectReport -UserId $managerId -All |
            Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.user' }

        foreach ($r in $reports) {
            if ($r.Id -eq $startUser.Id) {
                continue                            # skip the input user
            }
            $peer = Get-UserDetail -Id $r.Id
            if ($ExcludeDisabled -and -not $peer.AccountEnabled) {
                continue                            # skip disabled peers
            }
            $peerRows.Add((New-Row $upn 1 'Peer' $peer $manager.UserPrincipalName))
        }
    } else {
        Write-Host "  No manager set - no peers to list." -ForegroundColor Yellow
    }
}

# ---------------------------------------------------------------------------
# 8. EXPORT TO CSV
#    Two files with a timestamp so earlier runs are never overwritten.
# ---------------------------------------------------------------------------
$stamp     = Get-Date -Format 'yyyyMMdd_HHmmss'
$chainPath = Join-Path $OutputFolder "ManagerChain_$stamp.csv"
$peerPath  = Join-Path $OutputFolder "Peers_$stamp.csv"

$chainRows | Export-Csv -Path $chainPath -NoTypeInformation -Encoding UTF8
$peerRows  | Export-Csv -Path $peerPath  -NoTypeInformation -Encoding UTF8

# ---------------------------------------------------------------------------
# 9. SUMMARY ON SCREEN
# ---------------------------------------------------------------------------
Write-Host "`nManager chain : $chainPath" -ForegroundColor Green
Write-Host "Peers         : $peerPath"   -ForegroundColor Green

$chainRows | Format-Table InputUser, Level, Relationship, DisplayName, JobTitle, Status -AutoSize
$peerRows  | Format-Table InputUser, DisplayName, JobTitle, Status, ManagerUPN -AutoSize

Tips

ScenarioTip
Chain stops earlyThe next person up has no manager set in Entra ID / HR sync
Peer list missing someoneTheir manager attribute points to a different person
Large batchesThe cache avoids repeat calls; for thousands of users consider app-only auth
Contacts in direct reportsFiltered out on purpose – only user objects are exported
Disabled manager in the chainThe script warns on screen; update the manager attribute in Entra ID or your HR source

References

No comments:

Post a Comment

Featured Post

Export a User's Manager Chain and Peers to CSV with Microsoft Graph PowerShell

Ever needed to answer “Who is my manager’s manager, all the way to the top?” or “Who else reports to my manager?” for an audit, an approv...

Popular posts