DEV Community

AdminPackStudio
AdminPackStudio

Posted on

Read-only Microsoft Graph PowerShell inventory - least-privilege scopes, device and license exports, and a before/after diff for change evidence

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.All reads 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
Enter fullscreen mode Exit fullscreen mode

If you see ReadWrite scopes you didn't ask for, two good options are:

  1. Review the permissions on the Microsoft Graph PowerShell enterprise app with whoever owns app consent in your tenant.
  2. 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
Enter fullscreen mode Exit fullscreen mode

Notes:

  • -Property and Select-Object must 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).
  • ApproximateLastSignInDateTime is 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? -All handles 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

Hashes prove the files weren't edited after the fact:

Get-FileHash .\evidence\CHG-1234\*\*.csv -Algorithm SHA256 | Select-Object Path, Hash
Enter fullscreen mode Exit fullscreen mode

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):
Enter fullscreen mode Exit fullscreen mode

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.