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.
2. Peers – everyone else who reports to the same manager.
All names above are fictional sample users on the contoso.com demo domain.
How it works
Prerequisites
| Item | Details |
|---|---|
| PowerShell | Windows PowerShell 5.1 or PowerShell 7+ |
| Module | Install-Module Microsoft.Graph.Users -Scope CurrentUser |
| Permission | User.Read.All (delegated or application) – least-privileged for manager and direct reports |
| Consent | First sign-in may show a consent prompt; some tenants require admin consent |
Is it safe? Yes – it is read-only
| Cmdlet | Purpose | Changes tenant? |
|---|---|---|
Connect-MgGraph | Sign in with User.Read.All | No |
Get-MgUser | Read user properties | No |
Get-MgUserManager | Read manager link | No |
Get-MgUserDirectReport | Read direct reports | No |
Export-Csv | Write CSV on your local PC | No (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
| InputUser | Level | Relationship | DisplayName | JobTitle | Status | ManagerUPN |
|---|---|---|---|---|---|---|
| alex.wilber@contoso.com | 0 | User | Alex Wilber | Systems Engineer | Active | megan.bowen@contoso.com |
| alex.wilber@contoso.com | 1 | Manager L1 | Megan Bowen | IT Manager | Active | lynne.robbins@contoso.com |
| alex.wilber@contoso.com | 2 | Manager L2 | Lynne Robbins | Director, IT | Active | isaiah.langer@contoso.com |
| alex.wilber@contoso.com | 3 | Manager L3 | Isaiah Langer | VP Technology | Active | nestor.wilke@contoso.com |
| alex.wilber@contoso.com | 4 | Manager L4 | Nestor Wilke | Chief Executive Officer | Active |
Peers_<timestamp>.csv
| InputUser | Relationship | DisplayName | JobTitle | Status | ManagerUPN |
|---|---|---|---|---|---|
| alex.wilber@contoso.com | Peer | Diego Siciliani | Network Engineer | Active | megan.bowen@contoso.com |
| alex.wilber@contoso.com | Peer | Grady Archie | Cloud Engineer | Disabled | megan.bowen@contoso.com |
| alex.wilber@contoso.com | Peer | Johanna Lorenz | Support Analyst | Active | megan.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.
| # | Section | What it does |
|---|---|---|
| 1 | Parameters | Declares 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. |
| 2 | Connect to Microsoft Graph | Loads 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. |
| 3 | Settings and cache | Requests 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. |
| 4 | Helper: Get-UserDetail | A tiny wrapper around Get-MgUser that checks the cache first. It stores the object under both its Id and the UPN you passed in. |
| 5 | Helper: Get-ManagerId | Calls 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. |
| 6 | Helper: New-Row | Builds one consistent row object so both CSV files have the same columns, including a Status column (Active / Disabled) based on AccountEnabled. |
| 7 | Main loop – process each input user | The 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. |
| 8 | Export to CSV | Exports both result lists with Export-Csv. A timestamp in the file name means earlier runs are never overwritten. |
| 9 | Summary on screen | Prints 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
| Scenario | Tip |
|---|---|
| Chain stops early | The next person up has no manager set in Entra ID / HR sync |
| Peer list missing someone | Their manager attribute points to a different person |
| Large batches | The cache avoids repeat calls; for thousands of users consider app-only auth |
| Contacts in direct reports | Filtered out on purpose – only user objects are exported |
| Disabled manager in the chain | The script warns on screen; update the manager attribute in Entra ID or your HR source |
No comments:
Post a Comment