<?xml version="1.0"?>
<rss version="2.0"><channel><title><![CDATA[Scripting & Automation Latest Topics]]></title><link>https://wtmsp.com/forum/13-scripting-automation/</link><description><![CDATA[Scripting & Automation Latest Topics]]></description><language>en</language><item><title>The bulk rename that ate an invoice, and the dry-run habits we use now</title><link>https://wtmsp.com/topic/29-the-bulk-rename-that-ate-an-invoice-and-the-dry-run-habits-we-use-now/</link><description><![CDATA[<p>Bulk renames look trivial. Loop over the files, work out a new name, move. The trouble starts when two inputs map to the same output, because a plain <code>mv</code> or <code>Move-Item -Force</code> overwrites the first file with the second and says nothing. We learned this the hard way normalising a folder of scanned documents for a client. <code>Invoice 001.pdf</code> and <code>invoice_001.pdf</code> both became <code>invoice_001.pdf</code>, and one of them was simply gone.</p><p>These days every batch job I write follows the same habits. Here they are, with working bash and PowerShell versions.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>Before you run either script</strong></p></div><ul><li>bash 4 or newer for the associative array in the bash version. macOS ships bash 3.2, so install a newer bash or drop the collision map.</li><li>PowerShell 5.1 or 7.x for the PowerShell version.</li><li>A copy of the data, or at least a backup, for the first real run.</li></ul></div><h2>The rules I follow now</h2><ol><li>Dry run by default. The script only prints what it would do unless you explicitly tell it to act.</li><li>Detect collisions in the plan, before anything moves.</li><li>Never clobber. Check the destination, and use <code>mv -n</code> as a backstop.</li><li>Log every change with old and new names, so you can undo it.</li><li>Make it re-runnable. Files already in the target state get skipped, so running it twice is harmless and an interrupted run can just be restarted.</li></ol><h2>Bash</h2><pre class="ipsCode" data-language="bash">#!/usr/bin/env bash
# normalize-names.sh - lowercase names and replace spaces with underscores
# Usage: ./normalize-names.sh DIR           (dry run)
#        ./normalize-names.sh DIR --apply   (actually rename)
set -euo pipefail
DIR="${1:?usage: $0 DIR [--apply]}"
APPLY=0
[[ "${2:-}" == "--apply" ]] &amp;&amp; APPLY=1
LOG="rename-$(date +%Y%m%d-%H%M%S).log"
declare -A planned   # target -&gt; source, needs bash 4+

while IFS= read -r -d '' src; do
    dir=$(dirname "$src"); base=$(basename "$src")
    new=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | tr ' ' '_')
    [[ "$base" == "$new" ]] &amp;&amp; continue          # already done: re-runnable
    dest="$dir/$new"

    if [[ -n "${planned[$dest]:-}" ]]; then
        echo "COLLISION  $src and ${planned[$dest]} -&gt; $dest" | tee -a "$LOG"
        continue
    fi
    planned[$dest]="$src"

    if [[ -e "$dest" ]]; then
        echo "EXISTS     $src -&gt; $dest (skipped)" | tee -a "$LOG"
        continue
    fi

    if (( APPLY )); then
        mv -n -- "$src" "$dest"
        if [[ -e "$src" ]]; then
            echo "FAILED     $src -&gt; $dest" | tee -a "$LOG"
        else
            echo "RENAMED    $src -&gt; $dest" | tee -a "$LOG"
        fi
    else
        echo "WOULD      $src -&gt; $dest" | tee -a "$LOG"
    fi
done &lt; &lt;(find "$DIR" -maxdepth 1 -type f -print0)</pre><p>A few details in there are easy to miss:</p><ul><li><code>find -print0</code> with <code>read -d ''</code> handles spaces, quotes and even newlines in names. Never loop over <code>$(ls)</code>.</li><li><code>mv -n</code> (no clobber) refuses to overwrite an existing file. I don't trust its exit status to tell me it skipped, because that behaviour differs between platforms and coreutils versions, so the script checks whether the source still exists afterwards.</li><li>The <code>--</code> before the filenames stops a file called <code>-rf.txt</code> being read as options.</li></ul><h2>PowerShell</h2><p>PowerShell gives you dry runs for free. Declare <code>SupportsShouldProcess</code>, wrap the change in <code>$PSCmdlet.ShouldProcess()</code>, and <code>-WhatIf</code> works just like it does on built-in cmdlets.</p><pre class="ipsCode" data-language="powershell"># Normalize-Names.ps1 - run with -WhatIf first
[CmdletBinding(SupportsShouldProcess)]
param([Parameter(Mandatory)][string]$Path)

$log = "rename-{0:yyyyMMdd-HHmmss}.csv" -f (Get-Date)

$plan = Get-ChildItem -LiteralPath $Path -File | ForEach-Object {
    $new = $_.Name.ToLower() -replace '\s+', '_'
    [pscustomobject]@{ Source = $_.FullName; NewName = $new; Target = Join-Path $_.DirectoryName $new }
} | Where-Object { (Split-Path $_.Source -Leaf) -cne $_.NewName }   # skip already-normalized

# Two inputs mapping to the same output? Stop before touching anything.
$collisions = $plan | Group-Object Target | Where-Object Count -gt 1
if ($collisions) {
    foreach ($c in $collisions) { Write-Warning "Collision -&gt; $($c.Name): $($c.Group.Source -join ', ')" }
    throw 'Resolve collisions before renaming.'
}

$results = foreach ($item in $plan) {
    if (Test-Path -LiteralPath $item.Target) {
        [pscustomobject]@{ Source = $item.Source; Target = $item.Target; Result = 'SkippedExists' }
        continue
    }
    if ($PSCmdlet.ShouldProcess($item.Source, "Rename to $($item.NewName)")) {
        try {
            Rename-Item -LiteralPath $item.Source -NewName $item.NewName -ErrorAction Stop
            $r = 'Renamed'
        } catch { $r = "Failed: $($_.Exception.Message)" }
        [pscustomobject]@{ Source = $item.Source; Target = $item.Target; Result = $r }
    }
}
if ($results) { $results | Export-Csv -LiteralPath $log -NoTypeInformation -WhatIf:$false }</pre><p>Run it in two passes:</p><pre class="ipsCode" data-language="powershell">.\Normalize-Names.ps1 -Path D:\Scans\Incoming -WhatIf
.\Normalize-Names.ps1 -Path D:\Scans\Incoming</pre><p><code>Rename-Item</code> already refuses to overwrite an existing file, but we still test first so the skip lands in the log instead of showing up as an error. Keep <code>Move-Item -Force</code> out of rename scripts. <code>-Force</code> is exactly the overwrite you're trying to prevent.</p><h2>Read the plan before you apply it</h2><p>When the dry run finishes, I look for any COLLISION or warning lines first. Then any EXISTS lines, since a target that's already there usually means an earlier partial run or a genuine duplicate. Last, I check the count against what I expected. If the plan says it'll rename 4,000 files and you expected 40, stop.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Did anything go missing?</strong></p></div><ul><li>Count files before and after. The numbers must match. Renames never reduce the file count unless something got overwritten.</li><li>Run the script again in dry-run mode. It should report nothing to do.</li><li>Keep the log with the data. To undo, swap the columns and feed it back through the same no-clobber logic.</li></ul></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Where this has caught us out</strong></p></div><ul><li>Case-only renames on case-insensitive filesystems. On NTFS and default macOS APFS, <code>Report.PDF</code> to <code>report.pdf</code> looks like the destination "already exists" because it's the same file, so both scripts above will skip it. If case-only changes matter, go through a temporary name (<code>Report.PDF</code> to <code>Report.PDF.tmp</code> to <code>report.pdf</code>).</li><li>The in-script map only catches collisions within one run. The destination-exists check is what covers files left by earlier runs.</li><li>Write logs somewhere other than the target folder, or the next run may try to rename its own log.</li><li>OneDrive, Dropbox and DFS replication can lag or create conflict copies during a big rename. Pause sync or run it on the server side.</li></ul><p>I use the same pattern for other batch jobs too, like bulk AD changes, mailbox moves and clearing out old backups. Show the plan, check for collisions, then act, log it, and make sure a second run won't hurt.</p></div>]]></description><guid isPermaLink="false">29</guid><pubDate>Sun, 18 Oct 2026 20:39:45 +0000</pubDate></item><item><title>Finding stale AD computers and users without disabling something still in use</title><link>https://wtmsp.com/topic/28-finding-stale-ad-computers-and-users-without-disabling-something-still-in-use/</link><description><![CDATA[<p>Every AD we inherit has the same ghosts. There are computer accounts for laptops recycled years ago, users who left before the last audit, and "temp" accounts that quietly became permanent. A stale-object report is one of the first things we run in a new environment, and after that we schedule it monthly. Here's the script, plus the caveats that keep you from disabling the PC the front desk still logs into every morning.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>What you need</strong></p></div><ul><li>A domain-joined machine with the RSAT Active Directory module (<code>Import-Module ActiveDirectory</code>).</li><li>A normal domain user account. It can read these attributes, so you don't need Domain Admin just to report.</li><li>A share or folder to save the reports to.</li></ul></div><h2>Why lastLogonTimestamp lies a little</h2><p>AD has two attributes that matter here.</p><ul><li><code>lastLogon</code> is precise but <strong>not replicated</strong>. Each domain controller keeps its own value, so you'd have to query every DC and take the newest.</li><li><code>lastLogonTimestamp</code> <strong>is replicated</strong>, so one query gives a usable answer, but it's deliberately imprecise. It only updates when the existing value is older than <code>ms-DS-Logon-Time-Sync-Interval</code> (14 days by default) minus a random 0 to 5 days. In practice it can lag the real last logon by roughly 9 to 14 days.</li></ul><p>For a "not seen in 90 days" report that doesn't matter at all. For "did this user log in yesterday?" it's useless. Pick thresholds well above 14 days.</p><h2>Turning the number into a date</h2><p>The attribute is a Windows FILETIME (100-nanosecond intervals since 1 January 1601), and <code>[DateTime]::FromFileTime()</code> converts it to local time. Accounts that have never logged on have no value, and converting 0 gives you a date in 1601, so handle that explicitly. The AD module also exposes a ready-converted <code>LastLogonDate</code> property, which is fine to use. I convert manually so the behaviour is obvious to whoever reads the script after me.</p><h2>The script</h2><pre class="ipsCode" data-language="powershell"># Get-StaleADReport.ps1
Import-Module ActiveDirectory
$Days   = 90
$Cutoff = (Get-Date).AddDays(-$Days)
$Stamp  = Get-Date -Format 'yyyy-MM-dd'
$OutDir = '\\fileserver.contoso.com\IT\Reports\AD'

function Convert-LLT($value) {
    if ($value -and $value -gt 0) { [DateTime]::FromFileTime($value) } else { $null }
}

$computers = Get-ADComputer -Filter * -Properties LastLogonTimestamp, OperatingSystem, Enabled, Description |
    ForEach-Object {
        [pscustomobject]@{
            Name          = $_.Name
            Enabled       = $_.Enabled
            OS            = $_.OperatingSystem
            LastLogon     = Convert-LLT $_.LastLogonTimestamp
            Description   = $_.Description
            DN            = $_.DistinguishedName
        }
    } |
    Where-Object { -not $_.LastLogon -or $_.LastLogon -lt $Cutoff } |
    Sort-Object LastLogon

$users = Get-ADUser -Filter 'Enabled -eq $true' -Properties LastLogonTimestamp, whenCreated |
    ForEach-Object {
        [pscustomobject]@{
            SamAccountName = $_.SamAccountName
            Name           = $_.Name
            LastLogon      = Convert-LLT $_.LastLogonTimestamp
            Created        = $_.whenCreated
            DN             = $_.DistinguishedName
        }
    } |
    Where-Object { (-not $_.LastLogon -and $_.Created -lt $Cutoff) -or ($_.LastLogon -and $_.LastLogon -lt $Cutoff) } |
    Sort-Object LastLogon

$computers | Export-Csv "$OutDir\stale-computers-$Stamp.csv" -NoTypeInformation
$users     | Export-Csv "$OutDir\stale-users-$Stamp.csv"     -NoTypeInformation
"Stale (&gt; $Days days): $($computers.Count) computers, $($users.Count) enabled users"</pre><p>Look at the user filter. An account that has never logged on but was created yesterday isn't stale, so for those we compare <code>whenCreated</code> instead.</p><h2>Getting the report to someone</h2><p>We save dated CSVs to a restricted share so we can compare month to month. If you want it emailed, know that Microsoft has marked <code>Send-MailMessage</code> obsolete and it doesn't handle modern authentication well. For Microsoft 365 tenants we send through Graph's <code>sendMail</code> using an app registration with <code>Mail.Send</code>, scoped with an application access policy so it can only send as the reporting mailbox. A relay you control works too. Either way, attach the CSV or link to the share. Nobody wants hundreds of rows pasted into an email body.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Sanity checks before anyone disables anything</strong></p></div><ul><li>Pick three machines from the report and look them up in your RMM or endpoint manager. If they've been online recently, something's off. Check whether they're really using a different computer account name.</li><li>Pick one machine you know is used every day and confirm it's <em>not</em> on the report.</li><li>Cross-check a handful of users against HR's leaver list.</li></ul><p>Then go in stages. Disable the object, move it to a "Pending Delete" OU with a note in the description, wait 30 days, and only then delete. Disabling is reversible. Deleting without the Recycle Bin is not.</p></div><h2>A matching bug that made a report look clean</h2><p>This one isn't about lastLogonTimestamp, but it came out of the same kind of cleanup. We were comparing a device export against a client list to find machines assigned to the wrong client. Using <code>-like "*$client*"</code> meant a client called "Riverton" also matched "North Riverton", so devices were silently counted under the wrong client and the report looked fine. Match names exactly, with <code>-eq</code> or a lookup set:</p><pre class="ipsCode" data-language="powershell"># BAD: substring / wildcard match - "Riverton" also matches "North Riverton"
$devices | Where-Object { $_.ClientName -like "*$client*" }
$devices | Where-Object { $_.ClientName -match $client }   # regex, same problem

# GOOD: exact, case-insensitive comparison
$devices | Where-Object { $_.ClientName -eq $client }

# GOOD for large lists: a HashSet with a case-insensitive comparer
$known = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]$clientList.Name, [System.StringComparer]::OrdinalIgnoreCase)
$unmatched = $devices | Where-Object { -not $known.Contains($_.ClientName.Trim()) }</pre><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Accounts that look stale but aren't</strong></p></div><ul><li>Service accounts and appliances often authenticate in ways that don't update lastLogonTimestamp the way you'd expect, or they log on rarely. Exclude a known service-account OU or review those by hand.</li><li><strong>Cluster and virtual objects</strong> (cluster name objects, some SQL listener accounts) can look stale while being critical. Read the description and DN before you disable one.</li><li>Computer password age is a useful second opinion. Domain members change their machine password every 30 days by default, so a <code>PasswordLastSet</code> more than a few months old on a computer account is a strong sign the machine is gone.</li><li>Hybrid-joined devices may also show up in Entra ID. Clean up both, or the stale device keeps reappearing in your Intune reports.</li></ul></div>]]></description><guid isPermaLink="false">28</guid><pubDate>Sun, 18 Oct 2026 20:38:45 +0000</pubDate></item><item><title>Our approach to fleet-wide scripts in Tactical RMM, plus a Cloudflare 1010 surprise</title><link>https://wtmsp.com/topic/27-our-approach-to-fleet-wide-scripts-in-tactical-rmm-plus-a-cloudflare-1010-surprise/</link><description><![CDATA[<p>Running a script on one machine is easy. Running it on four hundred machines across a dozen clients, where half of them are laptops that drift in and out of the office, is how you find out whether the script was any good. We use Tactical RMM (TRMM) for this, but most of what follows carries over to any RMM. It's mostly about knowing what account the script runs as, getting output you can actually read afterwards, and not pushing to everyone on the first try.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>What to have ready</strong></p></div><ul><li>A working Tactical RMM instance with agents deployed.</li><li>An account that can manage scripts and run them on agents.</li><li>An API key (Settings &gt; Global Settings &gt; API Keys) if you want to drive it from your own tooling.</li><li>One or two test machines you're allowed to break.</li></ul></div><h2>Keep a library, not a pile of one-offs</h2><p>Anything you run more than once goes in the Script Manager with a clear name, a category, a description of what it changes, and a sensible default timeout. TRMM supports PowerShell, batch and Python. Ad-hoc commands from the console are fine for poking around, but if it changes state it belongs in the library, where someone else can review it and re-run it exactly the same way.</p><h2>Remember it runs as SYSTEM</h2><p>By default TRMM scripts run as <strong>SYSTEM</strong>. That's what you want for installing software, changing services, editing HKLM or anything else machine-wide. It also means a few things catch people out:</p><ul><li><code>HKCU</code> is SYSTEM's hive, not the user's.</li><li>Mapped drives and user-profile paths aren't what you expect.</li><li>Network access goes out as the computer account.</li></ul><p>The <strong>Run As User</strong> option runs the script in the logged-on user's session instead. It only works when somebody is actually signed in, so it's a poor fit for an overnight fleet run. For per-user settings we usually run as SYSTEM and loop through the user hives, or deploy the change somewhere that applies at sign-in.</p><h2>One script with parameters beats five copies</h2><p>Instead of keeping five near-identical scripts, accept parameters and pass them from the script's default arguments or at run time:</p><pre class="ipsCode" data-language="powershell">param(
    [string]$ServiceName = 'Spooler',
    [ValidateSet('Check','Restart')][string]$Mode = 'Check'
)</pre><p>Arguments are typed the way you would on a command line (for example <code>-ServiceName Spooler -Mode Restart</code>). Never pass secrets as plain arguments, because they end up in run history. Use the RMM's custom fields or environment variables where you can, and limit who can view history.</p><h2>Write it so running it twice is harmless</h2><p>A fleet script will hit machines in every state you can imagine, and someone will run it twice. Check before you change anything, make a second run a no-op, and report clearly. We start every output line with a fixed status word so results can be grouped later, and we always set an exit code:</p><pre class="ipsCode" data-language="powershell"># Ensure-BitLockerRecoveryEscrow.ps1 - safe to run repeatedly
$ErrorActionPreference = 'Stop'
try {
    $vol = Get-BitLockerVolume -MountPoint $env:SystemDrive
    if ($vol.ProtectionStatus -ne 'On') {
        Write-Output "SKIP: BitLocker not enabled on $env:SystemDrive"
        exit 0
    }
    $kp = $vol.KeyProtector | Where-Object KeyProtectorType -eq 'RecoveryPassword'
    if (-not $kp) {
        Write-Output "FAIL: no recovery password protector"
        exit 1
    }
    BackupToAAD-BitLockerKeyProtector -MountPoint $env:SystemDrive -KeyProtectorId $kp[0].KeyProtectorId | Out-Null
    Write-Output "OK: escrowed $($kp[0].KeyProtectorId)"
    exit 0
}
catch {
    Write-Output "FAIL: $($_.Exception.Message)"
    exit 1
}</pre><p>Don't skip the exit code. When a script is used as a check or an automated task, the RMM uses the return code to decide pass or fail. A script that catches every error and exits 0 will show green forever, and whoever's watching the dashboard has no idea machines are failing underneath it.</p><h2>Roll it out in rings</h2><p>Order matters here. This is the sequence we stick to:</p><ol><li>Run on one test machine and read the full output.</li><li>Run on a small, mixed group: a desktop, a laptop, an older OS build, a machine with a non-admin user.</li><li>Use bulk script against a single client or site.</li><li>Only then target everything, or attach it to an automation policy.</li></ol><h2>Put recurring work on a policy</h2><p>For anything recurring, add the script as an <strong>automated task</strong> on an automation policy instead of running it by hand every week. Policies apply at the client, site or agent level, so you can pilot on one site before widening it. Offline agents pick up their tasks when they come back, which beats chasing laptops around manually.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Counting the results</strong></p></div><p>Export the bulk results and count statuses. Eyeballing hundreds of rows is how things get missed.</p><pre class="ipsCode" data-language="powershell"># results.csv exported from the bulk run: hostname, output
Import-Csv .\results.csv | ForEach-Object {
    $status = ($_.output -split ':')[0].Trim()
    [pscustomobject]@{ Host = $_.hostname; Status = $status; Detail = $_.output }
} | Group-Object Status | Select-Object Name, Count</pre><p>Every machine should land in OK, SKIP or FAIL. Anything with no output at all usually timed out or was offline, so rerun those separately.</p></div><h2>Calling TRMM from your own scripts</h2><p>Once you're comfortable, you can trigger runs from your own tooling. Authentication is an <code>X-API-KEY</code> header. Check field names against your version, since they've changed over time. The easiest way I've found is to watch the request the web UI sends in your browser's developer tools.</p><pre class="ipsCode" data-language="powershell">$base    = 'https://api.rmm.contoso.com'
$headers = @{ 'X-API-KEY' = (Get-Secret -Name 'TrmmApiKey' -AsPlainText) }
# Cloudflare Browser Integrity Check blocked our POSTs without this
$ua      = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36'

# GET worked even without the User-Agent
$agents = Invoke-RestMethod -Uri "$base/agents/" -Headers $headers -UserAgent $ua

# POST returned 403 / error 1010 until -UserAgent was added
$body = @{
    script      = 42            # script ID from the library
    args        = @('-Mode', 'Check')
    output      = 'wait'
    timeout     = 120
    run_as_user = $false
} | ConvertTo-Json
$agentId = '&lt;AgentID&gt;'
Invoke-RestMethod -Method Post -Uri "$base/agents/$agentId/runscript/" `
    -Headers $headers -UserAgent $ua -ContentType 'application/json' -Body $body</pre><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>The 1010 error, and a few others</strong></p></div><p>Our RMM API sits behind Cloudflare. GET requests worked perfectly, which made the next part confusing. Every POST and DELETE came back <code>403</code> with Cloudflare error <code>1010</code>. Error 1010 means Cloudflare blocked the request based on the client's browser signature, usually via Browser Integrity Check. Scripted clients send a non-browser User-Agent, and only the write requests were being judged that harshly. Sending a normal browser User-Agent header fixed it straight away. The cleaner long-term fix is a WAF rule that skips Browser Integrity Check for the API hostname, restricted to your own source IPs.</p><ul><li>Timeouts are often too short. Installers on slow laptops easily run past a 30-second default, and a timed-out script may still be running on the endpoint.</li><li>"It works when I run it" usually means it works as you, in your session, with your drives mapped. SYSTEM is a different world.</li><li>Keep output short and structured. A script that dumps a full event log for every machine makes the results unreadable.</li></ul></div>]]></description><guid isPermaLink="false">27</guid><pubDate>Sun, 18 Oct 2026 20:37:45 +0000</pubDate></item><item><title>Unattended Graph reports from PowerShell with an app registration</title><link>https://wtmsp.com/topic/26-unattended-graph-reports-from-powershell-with-an-app-registration/</link><description><![CDATA[<p>Most of our tenant reporting runs overnight: device inventories, licence counts, stale-account checks. Nobody is sitting there to answer an MFA prompt at 2 a.m., and a job tied to one tech's sign-in breaks the week that person gets a new phone or moves on. So we run these jobs as app registrations using the OAuth2 <strong>client credentials</strong> flow. The script signs in as the app itself, gets a token, and calls Graph with plain <code>Invoke-RestMethod</code>. No modules are needed, which I appreciate on locked-down jump boxes where installing anything is its own ticket.</p><p>This is the small script we start from. It lists users and Intune managed devices, follows paging properly, and keeps the secret out of the file.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>Before you start</strong></p></div><ul><li>An account that can create app registrations and grant admin consent (Application Administrator, plus a Privileged Role Administrator or Global Administrator to consent to Graph application permissions).</li><li>PowerShell 5.1 or 7.x.</li><li>The <code>Microsoft.PowerShell.SecretManagement</code> and <code>Microsoft.PowerShell.SecretStore</code> modules if you go with a client secret.</li></ul></div><h2>Registering the app</h2><ol><li>In the Entra admin center, go to <strong>Applications &gt; App registrations &gt; New registration</strong>.</li><li>Give it a name the next tech will understand (we use something like <code>svc-graph-reporting</code>). Single tenant, no redirect URI.</li><li>Copy the <strong>Application (client) ID</strong> and <strong>Directory (tenant) ID</strong> from the Overview page.</li></ol><h2>Permissions, and the consent button everyone forgets</h2><p>Under <strong>API permissions &gt; Add a permission &gt; Microsoft Graph</strong>, pick <strong>Application permissions</strong>, not Delegated. Delegated permissions are for a signed-in user and do nothing in the client credentials flow. This script needs two:</p><ul><li><code>User.Read.All</code></li><li><code>DeviceManagementManagedDevices.Read.All</code></li></ul><p>Then click <strong>Grant admin consent for &lt;tenant&gt;</strong>. This is the step I always forget on a new tenant. Until the status column shows a green tick, you'll still get a token, but every call comes back 403. Ask only for the read permissions you actually need. An app secret with <code>Directory.ReadWrite.All</code> is a tenant-wide key sitting on some server, and I don't want to be the one explaining that to a client.</p><h2>Where the credential lives</h2><p>Under <strong>Certificates &amp; secrets</strong> you can upload a certificate or create a client secret. I prefer certificates, because the private key stays in the machine's certificate store and never gets pasted into a file. If you do use a secret, give it a short expiry, put the renewal date on a calendar someone actually reads, and never hard-code it. We keep ours in a SecretStore vault on the box that runs the job:</p><pre class="ipsCode" data-language="powershell">Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name LocalStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
Set-Secret -Name 'GraphReportApp' -Secret (Read-Host -AsSecureString 'Client secret')</pre><p>Run that as the same account the scheduled task runs as, because the vault is per-user. For unattended use you'll also need to set the SecretStore's password behaviour to suit your environment (see <code>Set-SecretStoreConfiguration</code>).</p><h2>Getting a token and following the pages</h2><p>The token endpoint is <code>https://login.microsoftonline.com/&lt;TenantID&gt;/oauth2/v2.0/token</code>. With client credentials the scope is always <code>https://graph.microsoft.com/.default</code>, which means "whatever application permissions have been consented". The response contains <code>access_token</code>, which is good for roughly an hour.</p><p>Graph hands results back in pages. When there are more, the response includes an <code>@odata.nextLink</code> property with the full URL of the next page, and you keep following it until it's gone. If you skip this, the report quietly stops at the first page (100 users by default). Nobody notices for months, until someone asks why the licence count looks low.</p><h3>The full script</h3><pre class="ipsCode" data-language="powershell"># Get-GraphInventory.ps1 - list users and Intune managed devices via Microsoft Graph
# Requires: Microsoft.PowerShell.SecretManagement + a registered vault holding the secret
$TenantId = '&lt;TenantID&gt;'
$ClientId = '&lt;AppClientID&gt;'
$Secret   = Get-Secret -Name 'GraphReportApp' -AsPlainText

function Get-GraphToken {
    $body = @{
        client_id     = $ClientId
        scope         = 'https://graph.microsoft.com/.default'
        client_secret = $Secret
        grant_type    = 'client_credentials'
    }
    $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    (Invoke-RestMethod -Method Post -Uri $tokenUri -Body $body `
        -ContentType 'application/x-www-form-urlencoded').access_token
}

function Invoke-GraphPaged {
    param([Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][hashtable]$Headers)
    $all = [System.Collections.Generic.List[object]]::new()
    do {
        $page = Invoke-RestMethod -Method Get -Uri $Uri -Headers $Headers
        foreach ($item in $page.value) { $all.Add($item) }
        $Uri = $page.'@odata.nextLink'
    } while ($Uri)
    $all
}

$headers = @{ Authorization = "Bearer $(Get-GraphToken)" }

# Single quotes matter: in double quotes PowerShell would try to expand $select
$users = Invoke-GraphPaged -Headers $headers `
    -Uri 'https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName,accountEnabled&amp;$top=999'

$devices = Invoke-GraphPaged -Headers $headers `
    -Uri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=deviceName,operatingSystem,osVersion,lastSyncDateTime,userPrincipalName'

"{0} users, {1} managed devices" -f $users.Count, $devices.Count
$devices | Sort-Object lastSyncDateTime |
    Select-Object deviceName, operatingSystem, osVersion, lastSyncDateTime, userPrincipalName |
    Export-Csv -Path .\managed-devices.csv -NoTypeInformation</pre><h3>If you'd rather use a certificate</h3><p>Doing certificate auth with raw <code>Invoke-RestMethod</code> means building and signing a JWT client assertion yourself. It's doable but fiddly, so when we use certificates we let the Microsoft Graph PowerShell SDK do that part:</p><pre class="ipsCode" data-language="powershell"># Certificate auth with the Graph SDK - no secret on disk at all
Connect-MgGraph -TenantId '&lt;TenantID&gt;' -ClientId '&lt;AppClientID&gt;' -CertificateThumbprint '&lt;Thumbprint&gt;'
Get-MgDeviceManagementManagedDevice -All | Select-Object DeviceName, OperatingSystem, LastSyncDateTime</pre><p>It's the same app registration with the same permissions. You just upload the public key (.cer) under Certificates &amp; secrets.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Checking the token before you trust the report</strong></p></div><pre class="ipsCode" data-language="powershell">$t = Get-GraphToken
$t.Length            # a long string, not an error
($t -split '\.')[1]  # middle segment of the JWT - decode it to check the "roles" claim</pre><ul><li>Decode the middle part of the token with a JWT decoder you trust, or base64-decode it locally. The <code>roles</code> claim should list <code>User.Read.All</code> and <code>DeviceManagementManagedDevices.Read.All</code>. If <code>roles</code> is missing, consent hasn't been granted.</li><li>Compare the device count in the CSV with the count in the Intune admin center. If yours comes out at exactly 100, 999 or 1000, paging is broken.</li></ul></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Things that have bitten us</strong></p></div><ul><li>Double-quoted URLs. <code>"...users?$select=..."</code> expands <code>$select</code> to an empty string, and Graph happily returns every property. Use single quotes, or escape the dollar sign with a backtick.</li><li>Long runs outlive the token. A big tenant can take more than an hour to page through, so on long jobs request a fresh token before the old one expires instead of reusing one for the whole run.</li><li>Throttling. Push Graph too hard and it returns 429 with a <code>Retry-After</code> header. Wrap your calls in a retry that honours that header rather than hammering it.</li><li><strong>Expired secrets.</strong> This is the most common "the report stopped" ticket we get. The error is <code>AADSTS7000222</code> (expired client secret). Track expiry dates somewhere you actually look.</li><li>Adding the Delegated version of a permission looks right in the portal and does nothing for this flow.</li></ul></div>]]></description><guid isPermaLink="false">26</guid><pubDate>Sun, 18 Oct 2026 20:36:45 +0000</pubDate></item></channel></rss>
