Read-only Microsoft Graph inventory: least privilege, dated exports, and a diff for every change
"What did that change actually do?" is a question most change tickets can't answer. The ticket says "updated device cleanup rule" or "reassigned E3 licenses", and then the evidence is a screenshot or nothing at all.
A small, read-only Graph inventory fixes that. You export the same data before and after a change, diff the two, and attach both files to the ticket. The same exports also answer the monthly hygiene questions: stale devices, disabled users who still hold licenses, and SKUs you're paying for but not using.
This post covers the read-only setup, two exports (Entra devices and user licenses), and the diff. Permission names and module behavior change over time, so check the current Microsoft Graph docs before you copy anything.
Related reading: The 30-minute Monday habit: weekly read-only Intune snapshots covers the Intune managed-device side. This post covers the Entra directory side.
1. Read-only on purpose
An inventory job should be unable to change anything, not just written so it doesn't.
Pick the smallest read scopes
| Data | Starter scope | Notes |
|---|---|---|
| Entra devices | Device.Read.All |
Directory device objects (joined/registered), not Intune managed devices |
| Users | User.Read.All |
Basic profile, accountEnabled, assignedLicenses
|
| Subscribed SKUs | LicenseAssignment.Read.All |
Listed as least-privileged for subscribedSkus in the current docs. Organization.Read.All also works |
| Sign-in activity on users | AuditLog.Read.All |
signInActivity also needs Entra ID P1 or P2 |
| Conditional Access policies | Policy.Read.All |
Only if you're exporting CA too |
| Intune managed devices | DeviceManagementManagedDevices.Read.All |
Only if you're exporting Intune too |
If someone suggests Directory.ReadWrite.All "to make the export easier", the answer is no. Nothing in an inventory needs a write scope.
Delegated vs app-only
- Delegated (interactive sign-in): what you can do is the intersection of the consented scopes and the signed-in user's Entra role. Pair read scopes with a read role such as Global Reader, and even a mistake in the script can't write anything. This is the safest place to start.
-
App-only (certificate, for scheduling): there's no user role in the way. An application permission like
User.Read.Allreads every user in the tenant. That's fine for an inventory, but it's the reason app-only registrations need to stay strictly read-only, use a certificate rather than a client secret where you can, and have more than one owner.
The consent gotcha
When you run Connect-MgGraph -Scopes ... with the default Microsoft Graph PowerShell app, consented scopes build up over time on that enterprise app. Your session may hold write scopes that someone consented to months ago, even though you only asked for read scopes today. Check:
Connect-MgGraph -Scopes 'Device.Read.All','User.Read.All','LicenseAssignment.Read.All' -NoWelcome
Get-MgContext | Select-Object Account, TenantId, AuthType
(Get-MgContext).Scopes | Sort-Object
If you see ReadWrite scopes you didn't ask for, two good options are:
- Review the permissions on the Microsoft Graph PowerShell enterprise app with whoever owns app consent in your tenant.
- Create a dedicated single-tenant app registration for inventory that holds only read permissions, and connect with
Connect-MgGraph -ClientId <appId> -TenantId <tenantId>. Record who granted consent, which scopes, and why.
2. Export Entra devices
$stamp = Get-Date -Format 'yyyy-MM-dd'
$out = ".\inventory\$stamp"
New-Item -ItemType Directory -Path $out -Force | Out-Null
$props = 'id','deviceId','displayName','operatingSystem','operatingSystemVersion',
'trustType','accountEnabled','approximateLastSignInDateTime'
Get-MgDevice -All -Property $props |
Select-Object Id, DeviceId, DisplayName, OperatingSystem, OperatingSystemVersion,
TrustType, AccountEnabled, ApproximateLastSignInDateTime |
Export-Csv "$out\entra-devices.csv" -NoTypeInformation -Encoding UTF8
Notes:
-
-PropertyandSelect-Objectmust match. When you pass-Property, Graph only returns those fields. Anything else you select comes back empty, and an empty column looks like a data problem when it's really a query problem. -
TrustType:AzureAd= Entra joined,ServerAd= hybrid joined (on-prem domain joined),Workplace= registered (often BYOD). -
ApproximateLastSignInDateTimeis approximate by design. It's good enough to find devices that haven't been seen in months. It isn't good enough to prove a device was offline on a particular day. -
Big tenant?
-Allhandles paging for you. For a first test, use-Top 50.
Questions this export answers in a minute in Excel or PowerShell:
$dev = Import-Csv "$out\entra-devices.csv"
$cutoff = (Get-Date).AddDays(-90)
# Stale: not seen in 90+ days
($dev | Where-Object { $_.ApproximateLastSignInDateTime -and [datetime]$_.ApproximateLastSignInDateTime -lt $cutoff }).Count
# Join type mix
$dev | Group-Object TrustType | Select-Object Name, Count
# Duplicate display names (often re-imaged devices that left an old object behind)
$dev | Group-Object DisplayName | Where-Object Count -gt 1 | Select-Object Name, Count
Stale devices are a cleanup candidate list, not a delete list. Disabling and deleting device objects can break BitLocker key recovery and Autopilot records if you're not careful, so cleanup is its own reviewed change.
3. Export users and licenses
Licenses come back as SKU GUIDs. Build a lookup from your subscribed SKUs first so the CSV is readable.
$skus = Get-MgSubscribedSku -All
$skuMap = @{}
$skus | ForEach-Object { $skuMap[[string]$_.SkuId] = $_.SkuPartNumber }
# License utilization: what you pay for vs what you use
$skus | Select-Object SkuPartNumber,
@{n='Enabled';e={$_.PrepaidUnits.Enabled}}, ConsumedUnits,
@{n='Unused';e={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} |
Export-Csv "$out\sku-utilization.csv" -NoTypeInformation -Encoding UTF8
# Users with readable SKU names
Get-MgUser -All -Property 'id','displayName','userPrincipalName','accountEnabled','userType','createdDateTime','assignedLicenses' |
Select-Object Id, DisplayName, UserPrincipalName, AccountEnabled, UserType, CreatedDateTime,
@{n='Skus';e={ ($_.AssignedLicenses | ForEach-Object { $skuMap[[string]$_.SkuId] }) -join ';' }},
@{n='LicenseCount';e={ $_.AssignedLicenses.Count }} |
Export-Csv "$out\users-licenses.csv" -NoTypeInformation -Encoding UTF8
Three hygiene checks worth running every month:
$u = Import-Csv "$out\users-licenses.csv"
# Disabled but still licensed
$u | Where-Object { $_.AccountEnabled -eq 'False' -and [int]$_.LicenseCount -gt 0 } |
Select-Object DisplayName, UserPrincipalName, Skus
# Guests holding licenses
$u | Where-Object { $_.UserType -eq 'Guest' -and [int]$_.LicenseCount -gt 0 } |
Select-Object DisplayName, UserPrincipalName, Skus
The third one is "licensed but never signed in". That needs signInActivity, which means AuditLog.Read.All plus Entra ID P1/P2. Microsoft's docs also note a smaller maximum page size when you select it. Add it only if your tenant has the license.
Before anyone reclaims a license based on these lists, check with the licensing owner. Some disabled accounts stay licensed on purpose, for example shared mailboxes over the size limit or mailboxes on hold.
4. The before/after diff (change evidence)
This is where the inventory pays for itself. The pattern:
1. Export BEFORE the change -> evidence\CHG-1234\pre\
2. Make the change under the CHG ID (portal or approved process)
3. Export AFTER the change -> evidence\CHG-1234\post\
4. Diff the counts and the keys
5. Attach both CSVs, their hashes, and the diff summary to the ticket
Object-level diff (what appeared or disappeared):
$pre = Import-Csv .\evidence\CHG-1234\pre\entra-devices.csv
$post = Import-Csv .\evidence\CHG-1234\post\entra-devices.csv
Compare-Object $pre $post -Property DeviceId -PassThru |
Select-Object DisplayName, DeviceId, SideIndicator
# '<=' only in BEFORE (removed), '=>' only in AFTER (added)
Field-level diff (what changed on objects that exist in both):
$preById = @{}
$pre | ForEach-Object { $preById[$_.DeviceId] = $_ }
$post | Where-Object { $preById.ContainsKey($_.DeviceId) -and
$preById[$_.DeviceId].AccountEnabled -ne $_.AccountEnabled } |
Select-Object DisplayName, DeviceId,
@{n='Before';e={$preById[$_.DeviceId].AccountEnabled}},
@{n='After';e={$_.AccountEnabled}}
License changes by SKU:
$preU = Import-Csv .\evidence\CHG-1234\pre\sku-utilization.csv
$postU = Import-Csv .\evidence\CHG-1234\post\sku-utilization.csv
foreach ($p in $postU) {
$b = $preU | Where-Object SkuPartNumber -eq $p.SkuPartNumber
[pscustomobject]@{ Sku = $p.SkuPartNumber; Before = $b.ConsumedUnits; After = $p.ConsumedUnits }
}
Hashes prove the files weren't edited after the fact:
Get-FileHash .\evidence\CHG-1234\*\*.csv -Algorithm SHA256 | Select-Object Path, Hash
A short evidence block for the ticket:
CHG-ID:
Pre-export path / SHA256:
Post-export path / SHA256:
Count deltas (devices, users, licensed users, per-SKU consumed):
Unexpected deltas and what we found:
Verified by (role):
The "unexpected deltas" line matters most. If a license change was supposed to touch 40 users and the diff shows 55, you want to find that out the same day, not at the next true-up.
5. Scheduling and storage, briefly
- Start interactive. Run it by hand monthly and before changes until the output is trusted.
- When you schedule it, use an app-only registration with read-only application permissions and a certificate. Put the certificate's expiry date in a shared calendar. Never put secrets in the script, the README, or a ticket.
- Watch for silent failures. An expired certificate or a removed consent gives you an empty or missing CSV, which looks a lot like "nothing changed". Alert on job failures and on exports that are suspiciously small.
- Treat exports as internal data. They contain UPNs, device names, and tenant IDs. Store them in an access-controlled location with a retention rule. Don't paste them into forums or public AI tools. Share counts or redacted rows instead.
6. Common snags
| Symptom | Likely cause | Fix |
|---|---|---|
| Columns empty in the CSV | Property not requested in -Property
|
Make -Property and Select-Object match |
| 403 / insufficient privileges | Scope not consented, or the user's role doesn't cover it | Get the right read scope consented and a read role assigned |
| Session has ReadWrite scopes | Consent built up on the shared Graph PowerShell app | Review app permissions or use a dedicated read-only app |
| SKU column shows GUIDs | No SKU lookup, or SKU removed from the tenant | Build the map from Get-MgSubscribedSku; keep GUIDs for unknowns |
signInActivity empty |
Missing AuditLog.Read.All or Entra ID P1/P2 |
Add both, or skip that column |
| Throttling (429) on big tenants | Too many requests too fast | Run off-hours, request fewer properties, and let the SDK retry |
| Wrong tenant exported | Multiple tenants, stale context | Check Get-MgContext before every run |
Want the ready-made toolkit?
The Microsoft Graph Read-Only Inventory Toolkit ($24) from Admin Pack Studio packages this approach into five Markdown modules: a least-privilege read-only app registration checklist with a starter scope table and consent evidence, a device inventory pattern, user and license orphan hunts, scheduling options with a secrets hygiene checklist, and change-control evidence with diff habits and troubleshooting cards. It also includes three read-only Graph PowerShell scripts: an Entra device inventory, a user and assigned-license inventory with SKU names, and a Conditional Access policy summary. The scripts only read and export to CSV. They don't create, update, or delete anything.
Get the toolkit: https://cashflow4375.gumroad.com/l/microsoft-graph-read-only-inventory-toolkit?utm_source=devto&utm_medium=article&utm_campaign=graph_inventory
Managing Intune devices too? The Intune & M365 Admin Starter Pack covers enrollment hygiene, compliance baselines, and read-only managed-device snapshots: https://cashflow4375.gumroad.com/l/joonf
Admin Pack Studio. Not affiliated with Microsoft. Microsoft Graph, Entra ID, Intune, and Microsoft 365 are Microsoft products. Operational guidance for admins authorized to manage their tenant. Permission names, license requirements, and SDK behavior change, so check current Microsoft documentation. Examples use placeholder names.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.