<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>SkyBytes blog</title>
  <subtitle>Microsoft 365, normenkaders en automatisering.</subtitle>
  <link href="https://skybytes.io/blog/feed.xml" rel="self"/>
  <link href="https://skybytes.io/blog/"/>
  <updated>2026-07-29T00:00:00Z</updated>
  <id>https://skybytes.io/blog/</id>
  <author><name>Kevin Oosterlaken</name></author>
  <entry>
    <title>Are you in control under the Cyberbeveiligingswet? Prove it in Microsoft 365, per BIO2 measure</title>
    <link href="https://skybytes.io/blog/cyberbeveiligingswet-in-control/"/>
    <updated>2026-07-29T00:00:00Z</updated>
    <id>https://skybytes.io/blog/cyberbeveiligingswet-in-control/</id>
    <summary>The CBW makes cybersecurity a personal board responsibility, with fines up to 10 million euro. &#39;We think we&#39;re in control&#39; won&#39;t survive an audit. Here&#39;s how to prove it, per BIO2 measure, in Microsoft 365.</summary>
    <content type="html"><![CDATA[<p>On August 15, 2026, being &quot;in control&quot; of your cybersecurity stops being a figure of speech. That's the day the <strong>Cyberbeveiligingswet (CBW)</strong>, the Dutch implementation of the EU NIS2 directive, enters into force. It lands on roughly <a href="https://www.digitaleoverheid.nl/overzicht-van-alle-onderwerpen/cyberbeveiligingswet/">8,000 organisations</a> in the Netherlands, and it moves the accountability up a floor: to the board.</p>
<p>Here's the part that changes the Tuesday-afternoon conversation. Under the CBW, the duty of care isn't something you can fully delegate to IT. Board members can be held personally accountable, supervisor the <strong>RDI</strong> (Rijksinspectie Digitale Infrastructuur) can direct measures at individual directors, and the fines reach up to <strong>10 million euro or 2% of annual turnover</strong>. There's even a mandatory training obligation for directors. &quot;In control&quot; just became a word a director has to be able to defend, not a box IT ticks.</p>
<h2>What the CBW actually asks of you</h2>
<p>The law is short on buttons and long on outcomes. Three obligations do most of the work:</p>
<ul>
<li><strong>Zorgplicht (duty of care).</strong> Run a risk analysis and take appropriate technical and organisational measures. It explicitly extends to your suppliers and chain partners, not just your own tenant.</li>
<li><strong>Meldplicht (incident reporting).</strong> A significant incident gets an early warning within <strong>24 hours</strong>, a fuller report within <strong>72 hours</strong>, and a final report within <strong>one month</strong>. (<a href="https://www.rdi.nl/onderwerpen/digitale-weerbaarheid/cyberbeveiligingswet/meldplicht">RDI: Meldplicht</a>)</li>
<li><strong>Registratieplicht.</strong> You register your organisation with the supervisor so they know you exist and which regime you fall under.</li>
</ul>
<p><img src="meldplicht-klok.svg" alt="Reporting timeline: incident found, early warning within 24 hours, report with analysis within 72 hours, full final report within 1 month"></p>
<p>For government bodies, the duty of care lines up with a standard you already know: the <strong>Baseline Informatiebeveiliging Overheid, BIO2</strong>. And that's where &quot;in control&quot; gets concrete. BIO2 tells you <em>what</em> must be true. It does not tell you <em>which setting in Microsoft 365</em> makes it true, or how to prove it still is.</p>
<p>That gap is the whole problem. A policy binder that says &quot;we enforce MFA&quot; is not evidence. An auditor, and now a supervisor, wants the list: which measure, which setting, and a check anyone can re-run to see it's really there.</p>
<h2>&quot;In control&quot; is a verb, not a binder</h2>
<p>The uncomfortable truth about most compliance programmes is that they're built on assertion. Someone wrote &quot;access is reviewed annually&quot; in a document in 2023, and everyone has been nodding at it since. Nobody has run the query that proves a review actually recurred.</p>
<p>Being in control means the opposite: for every measure that matters, you can point at the setting and produce a read-only command that shows its live state. Not a screenshot from last quarter. The state, today, on demand.</p>
<p>So we built the thing that closes the gap between the BIO2 measure and the Microsoft 365 reality: the <strong><a href="/bio2.html">BIO2 control map</a></strong>. Every mapped government measure gets three things:</p>
<ol>
<li><strong>The concrete M365 setting</strong> that implements it (Conditional Access, a Purview retention policy, an Intune compliance policy, a PIM assignment).</li>
<li><strong>The licence it needs</strong>, named exactly, because this is where claims quietly break.</li>
<li><strong>A read-only PowerShell command</strong> you run in your own tenant to verify it. Trust, but verify.</li>
</ol>
<p><img src="law-to-proof.svg" alt="From CBW duty of care to BIO2 measure to Microsoft 365 setting to a read-only PowerShell proof"></p>
<p>It also does the honest thing most vendor mappings won't: it flags the measures where <strong>Microsoft 365 is not the answer</strong>. Backup (BIO2 8.13) is the clearest one. M365 has retention, versioning and a recycle bin, none of which is a backup that survives ransomware or a bad bulk edit that syncs perfectly. Ticking that box with &quot;it's in the cloud&quot; is exactly the kind of assumption a supervisor is now empowered to punish.</p>
<h2>You don't need the most expensive licence to be in control</h2>
<p>Here's the myth worth killing before it costs you a renewal: that CBW readiness means buying Microsoft 365 E5 for everyone. It doesn't.</p>
<p>Walk the control map and a pattern shows up. A large share of the provable measures sit on licences you very likely already own:</p>
<ul>
<li><strong>Conditional Access MFA, device compliance, admin-portal protection, Terms of Use</strong> for provable acceptance of policies: <strong>Entra ID P1</strong>, which ships in <strong>Microsoft 365 Business Premium</strong> and E3.</li>
<li><strong>Intune compliance and app protection policies, removable-media control, endpoint hardening</strong>: <strong>Intune Plan 1</strong>, also in Business Premium and E3.</li>
<li><strong>Unified audit log, DKIM and DMARC, sensitivity labels, retention policies, Secure Score</strong>: <strong>included</strong> or in <strong>E3</strong>.</li>
</ul>
<p>E5 buys you the long tail: automatic labelling, Audit (Premium) retention beyond 180 days, Defender XDR, attack simulation training. Real value, but not the entry ticket. The sharper move is to <em>know which control sits on which shelf</em>, prove everything your current licence already covers, and make a deliberate, documented decision about the gaps, rather than panic-buying the top SKU. The control map names the shelf for every measure, and the companion <strong><a href="/licensing.html">Licensing</a></strong> tool decodes the SKUs.</p>
<h2>The one number that moves: prove the trend</h2>
<p>You can't screenshot &quot;in control.&quot; But you can produce a signal that changes over time, which is exactly what a PDCA cycle and a board report need. In Microsoft 365 that signal is <strong>Microsoft Secure Score</strong>, and it's read-only, licence-included, and has history.</p>
<p>This is the smallest possible &quot;prove it&quot; you can run today. It won't make you compliant, but it turns &quot;we're improving our security posture&quot; from a sentence into a line on a graph.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment">&lt;#
.SYNOPSIS
    Pulls the Microsoft Secure Score trend so you can show posture over time,
    not a single point-in-time claim. Read-only.

.DESCRIPTION
    Secure Score is the one built-in M365 measurement with history per control.
    The trend is the evidence a board report and a PDCA cycle actually need:
    proof the line moves, in the right direction. This does not change anything.

.NOTES
    Required Graph scope : SecurityEvents.Read.All
    Module               : Microsoft.Graph.Security

    Secure Score is NOT a BIO2 score. It's a useful trend to underpin your
    'in control' story, never a compliance percentage. The weighting is
    Microsoft's, not the BIO2's.
#></span>

<span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes SecurityEvents<span class="token punctuation">.</span>Read<span class="token punctuation">.</span>All

<span class="token comment"># The trend is the evidence, not the snapshot. Grab the last 12 measurements.</span>
<span class="token function">Get-MgSecuritySecureScore</span> <span class="token operator">-</span>Top 12 <span class="token punctuation">|</span>
    <span class="token function">Select-Object</span> CreatedDateTime<span class="token punctuation">,</span> CurrentScore<span class="token punctuation">,</span> MaxScore<span class="token punctuation">,</span>
        @<span class="token punctuation">{</span>n = <span class="token string">'Percentage'</span><span class="token punctuation">;</span> e = <span class="token punctuation">{</span> <span class="token namespace">[math]</span>::Round<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>CurrentScore <span class="token operator">/</span> <span class="token variable">$_</span><span class="token punctuation">.</span>MaxScore <span class="token operator">*</span> 100<span class="token punctuation">,</span> 1<span class="token punctuation">)</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span> <span class="token punctuation">|</span>
    <span class="token function">Sort-Object</span> CreatedDateTime</code></pre>
<p>A rising line is a fair thing to put in front of a director. A flat line since 2024 is a conversation you'd rather have with yourself than with the RDI. Just don't dress the percentage up as a BIO2 compliance figure: the weighting is Microsoft's, and a supervisor will know the difference.</p>
<h2>Where to start before August 15</h2>
<p>You have just over two weeks. Nobody closes NIS2 in two weeks, and anybody selling you that is selling. What you <em>can</em> do is stop guessing and start proving:</p>
<ol>
<li>Run the Secure Score trend above. That's your baseline and your first board slide.</li>
<li>Open the <strong><a href="/bio2.html">BIO2 control map</a></strong>, filter to <strong>GEDEKT</strong> (covered), and run the read-only check for the measures you claim to have in place. Confirm the policy is actually <em>enabled</em>, not sitting in report-only.</li>
<li>Filter to <strong>NIET</strong> (not covered by M365) and write those down as deliberate decisions with a compensating control, not silent gaps. That list, honestly kept, is itself evidence of a working duty of care.</li>
</ol>
<p>Being in control was never the day you configured the tenant carefully. It's the day you went back and checked, wrote down what you found, and could hand the whole thing to someone who's allowed to fine your director. The CBW just set a date on it.</p>
<p>Want the control map folded into a recurring, evidenced BIO2 baseline for your tenant before the deadline? <a href="/#contact">Let me know</a>.</p>
<hr>
<p><em>Sources: <a href="https://www.digitaleoverheid.nl/overzicht-van-alle-onderwerpen/cyberbeveiligingswet/">Digitale Overheid, Cyberbeveiligingswet</a> · <a href="https://www.rdi.nl/onderwerpen/digitale-weerbaarheid/cyberbeveiligingswet/meldplicht">RDI, Meldplicht Cyberbeveiligingswet</a> · <a href="https://www.rdi.nl/onderwerpen/digitale-weerbaarheid/cyberbeveiligingswet/bestuurlijke-verantwoordelijkheid">RDI, Bestuurlijke verantwoordelijkheid en governance</a>. BIO2 measure text: <a href="https://www.bio-overheid.nl/bio2/bio-producten/baseline-informatiebeveiliging-overheid-2-bio2/">bio-overheid.nl</a>.</em></p>
]]></content>
  </entry>
  <entry>
    <title>App registrations outlive the projects that created them. Here&#39;s how to audit them</title>
    <link href="https://skybytes.io/blog/app-registration-inventory/"/>
    <updated>2026-07-20T00:00:00Z</updated>
    <id>https://skybytes.io/blog/app-registration-inventory/</id>
    <summary>That integration someone wired up in 2022 still has a valid secret and Directory.ReadWrite.All. This read-only script inventories every app registration: how old, still used, and what it can actually do.</summary>
    <content type="html"><![CDATA[<p>Somewhere in your tenant is an app registration a developer created for a &quot;quick integration&quot; three years ago. It got a client secret, it got consented to <code>Directory.ReadWrite.All</code> because that was easier than working out the exact scope, and it went into a pipeline that may or may not still run. The developer left. The secret didn't expire. Nobody has thought about it since.</p>
<p>App registrations are the service accounts nobody offboards. They're non-human identities that authenticate with a secret or certificate, skip every MFA and conditional-access control you built for people, and hold whatever permissions they were granted on the day someone was in a hurry. A user who leaves gets offboarded. An app that outlives its purpose just keeps its keys.</p>
<h2>Why stale app registrations are a real risk</h2>
<p>An over-permissioned app registration is a better target than a user account. It has no phone to prompt, no password to rotate, and its credential is often sitting in a config file, a pipeline variable, or a wiki page. If that secret leaks and the app holds <code>Mail.Read</code> as an <strong>application</strong> permission, the attacker reads every mailbox in the tenant, not one. Consent granted once, in 2022, is still consent today.</p>
<p>This is exactly the surface auditors have started asking about: <strong>inventory of application identities, least privilege, and removal of what's no longer used.</strong> BIO2 and NIS2 both expect you to know which non-human identities exist, what they can do, and that you retire the ones that went dark. &quot;We review our app registrations&quot; is a policy. The auditor wants the list: every app, when it was created, whether anything still calls it, and which of them hold tenant-wide write permissions they've clearly never needed. Let's build that list.</p>
<h2>Requested is not granted</h2>
<p>Here's the distinction most app-registration audits get wrong, and it's the whole point of this one.</p>
<p>An app registration has two separate permission stories. <code>requiredResourceAccess</code> on the <strong>application</strong> is what the app <em>asks for</em>, the list you see under &quot;API permissions&quot; in the portal. The grants on its <strong>service principal</strong> are what the app <em>can actually do</em> in this tenant. They are not the same thing. An app can request <code>Directory.ReadWrite.All</code> in its manifest and never have been consented, in which case that scary-looking permission is a request nobody answered, not a live risk.</p>
<p>Read the manifest alone and you flag apps that can't do anything. Read the grants alone and you miss what an app is designed to reach for. You want both columns side by side: what it wanted, and what it got. The script resolves each API permission GUID to a real name like <code>Mail.Read</code> and marks whether it's actually granted, so <code>Directory.ReadWrite.All (requested, not granted)</code> reads very differently from <code>Directory.ReadWrite.All (granted)</code>.</p>
<p>When a name comes back and you're not sure how much it really grants, the <a href="/least-privilege.html">Least Privilege</a> tool does that lookup by hand: every Graph permission, delegated versus application, admin consent or not, with the tenant-wide ones flagged.</p>
<h2>The one signal that costs a licence, and the ones that don't</h2>
<p>&quot;Is anything still using this app?&quot; comes from <code>servicePrincipalSignInActivities</code>, a Graph report that rolls up the last time a service principal signed in across delegated and app-only flows. It's the honest answer to whether an app is a live integration or a museum piece.</p>
<p>It's also the expensive part. That report is a <strong><code>/beta</code> preview</strong> API, it needs an <strong><a href="/licensing.html">Entra ID P1 or P2</a></strong> licence and the <strong><code>AuditLog.Read.All</code></strong> permission, and it doesn't exist in US Gov or 21Vianet clouds. Miss any of those and the call fails.</p>
<p>So the script treats sign-in activity as optional. When the report runs, you get a real <code>UsageState</code> verdict per app. When it can't, the app doesn't lie to you: <code>UsageState</code> comes back as <code>Unknown</code> rather than a confident-but-false <code>NeverSignedIn</code>, and everything else, the creation dates, the ages, the requested-versus-granted permissions, still comes back on any tenant, licence or not. That last part matters, because two of the most damning findings, &quot;created four years ago&quot; and &quot;holds <code>Application.ReadWrite.All</code> and has an expired secret,&quot; need no premium licence at all.</p>
<p>One honesty caveat baked into the verdict: the sign-in report isn't backfilled. <code>NeverSignedIn</code> means &quot;no activity on record,&quot; not &quot;provably never used.&quot; Read it as a strong hint to investigate, not a death certificate.</p>
<h2>The script</h2>
<p>Read-only. It pulls every app registration, resolves its permissions to names, checks what's actually granted, folds in sign-in activity when it can, and flags the apps holding high-privilege permissions. Nothing is changed. By default it skips Microsoft-published apps, because your own registrations are almost always what you're auditing.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment">&lt;#
.SYNOPSIS
    Inventories every app registration in an Entra ID tenant: when it was created,
    whether it's still being used, and which API permissions it holds.

.DESCRIPTION
    Builds one report row per app registration (/applications) with creation date and
    age, last sign-in activity and a UsageState verdict, the API permissions the app
    both requests and has actually been granted (GUIDs resolved to names like
    'Mail.Read'), and credential expiry. Read-only: it changes nothing.

    Requested vs. granted matters. requiredResourceAccess on the application is what the
    app asks for; the grants on its service principal are what it can really do. An app
    can request Directory.ReadWrite.All and never have been consented.

    Sign-in activity comes from /beta/reports/servicePrincipalSignInActivities, which is
    preview-only and needs Entra ID P1/P2. If that call fails the report still runs and
    UsageState comes back as 'Unknown' rather than a misleading 'NeverSignedIn'.

.NOTES
    Required Graph scopes : Application.Read.All, Directory.Read.All, AuditLog.Read.All
    Required Entra role   : Global Reader (or Reports Reader + Application Administrator)
    Modules               : Microsoft.Graph.Authentication, Microsoft.Graph.Applications

    AuditLog.Read.All is only needed for sign-in activity; omit it with -SkipSignInActivity.
    The sign-in report is a /beta preview API, not available in US Gov or 21Vianet clouds.
#></span>

<span class="token namespace">[CmdletBinding()]</span>
<span class="token keyword">param</span><span class="token punctuation">(</span>
    <span class="token namespace">[ValidateRange(1, 3650)]</span>
    <span class="token namespace">[int]</span> <span class="token variable">$UnusedAfterDays</span> = 90<span class="token punctuation">,</span>

    <span class="token namespace">[switch]</span> <span class="token variable">$IncludeMicrosoftApps</span><span class="token punctuation">,</span>

    <span class="token namespace">[string]</span> <span class="token variable">$CsvPath</span><span class="token punctuation">,</span>

    <span class="token namespace">[string]</span> <span class="token variable">$PermissionCsvPath</span><span class="token punctuation">,</span>

    <span class="token namespace">[switch]</span> <span class="token variable">$SkipSignInActivity</span>
<span class="token punctuation">)</span>

<span class="token function">Set-StrictMode</span> <span class="token operator">-</span>Version Latest
<span class="token variable">$ErrorActionPreference</span> = <span class="token string">'Stop'</span>

<span class="token comment">#region Connection</span>

<span class="token variable">$requiredScopes</span> = @<span class="token punctuation">(</span><span class="token string">'Application.Read.All'</span><span class="token punctuation">,</span> <span class="token string">'Directory.Read.All'</span><span class="token punctuation">)</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$SkipSignInActivity</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$requiredScopes</span> <span class="token operator">+=</span> <span class="token string">'AuditLog.Read.All'</span> <span class="token punctuation">}</span>

<span class="token variable">$context</span> = <span class="token function">Get-MgContext</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$context</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token function">Write-Verbose</span> <span class="token string">"No Graph session; connecting with: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$requiredScopes</span> <span class="token operator">-join</span> <span class="token string">', '</span><span class="token punctuation">)</span></span>"</span>
    <span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token variable">$requiredScopes</span> <span class="token operator">-</span>NoWelcome
    <span class="token variable">$context</span> = <span class="token function">Get-MgContext</span>
<span class="token punctuation">}</span>
<span class="token keyword">else</span> <span class="token punctuation">{</span>
    <span class="token comment"># Reuse the caller's session, but only if it can actually do the job. Directory.Read.All</span>
    <span class="token comment"># is a superset of Application.Read.All, so accept either.</span>
    <span class="token variable">$granted</span> = @<span class="token punctuation">(</span><span class="token variable">$context</span><span class="token punctuation">.</span>Scopes<span class="token punctuation">)</span>
    <span class="token variable">$effective</span> = <span class="token variable">$granted</span> <span class="token operator">+</span> $<span class="token punctuation">(</span><span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$granted</span> <span class="token operator">-contains</span> <span class="token string">'Directory.Read.All'</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token string">'Application.Read.All'</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token variable">$missing</span> = <span class="token variable">$requiredScopes</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token operator">-notin</span> <span class="token variable">$effective</span> <span class="token punctuation">}</span>

    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$missing</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token function">Write-Verbose</span> <span class="token string">"Existing session is missing: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$missing</span> <span class="token operator">-join</span> <span class="token string">', '</span><span class="token punctuation">)</span></span>. Reconnecting."</span>
        <span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token variable">$requiredScopes</span> <span class="token operator">-</span>NoWelcome
        <span class="token variable">$context</span> = <span class="token function">Get-MgContext</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token function">Write-Verbose</span> <span class="token string">"Tenant: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$context</span><span class="token punctuation">.</span>TenantId<span class="token punctuation">)</span></span> as <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$context</span><span class="token punctuation">.</span>Account<span class="token punctuation">)</span></span>"</span>

<span class="token comment">#endregion</span>

<span class="token comment">#region Reference data</span>

<span class="token comment"># Well-known permissions that make an app a real problem if it's compromised. Used only</span>
<span class="token comment"># to flag rows for triage, a starting point for review, not a security verdict.</span>
<span class="token comment">#</span>
<span class="token comment"># Type matters, so there are two lists. Delegated permissions are bounded by the</span>
<span class="token comment"># signed-in user; application permissions are not. Mail.Read delegated reads the user's</span>
<span class="token comment"># own mailbox; Mail.Read as an application permission reads every mailbox in the tenant.</span>

<span class="token comment"># Dangerous either way: admin-consent, tenant-wide write permissions.</span>
<span class="token variable">$highPrivilegeAlways</span> = @<span class="token punctuation">(</span>
    <span class="token string">'Application.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'AppRoleAssignment.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'Directory.ReadWrite.All'</span><span class="token punctuation">,</span>
    <span class="token string">'RoleManagement.ReadWrite.Directory'</span><span class="token punctuation">,</span> <span class="token string">'PrivilegedAccess.ReadWrite.AzureADGroup'</span><span class="token punctuation">,</span>
    <span class="token string">'User.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'Group.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'GroupMember.ReadWrite.All'</span><span class="token punctuation">,</span>
    <span class="token string">'Domain.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'Policy.ReadWrite.ConditionalAccess'</span><span class="token punctuation">,</span> <span class="token string">'Sites.FullControl.All'</span><span class="token punctuation">,</span>
    <span class="token string">'DeviceManagementConfiguration.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'DeviceManagementManagedDevices.ReadWrite.All'</span>
<span class="token punctuation">)</span>

<span class="token comment"># Only dangerous as application permissions, where "All" means the whole tenant rather</span>
<span class="token comment"># than the one user who consented.</span>
<span class="token variable">$highPrivilegeAsApplication</span> = @<span class="token punctuation">(</span>
    <span class="token string">'Mail.Read'</span><span class="token punctuation">,</span> <span class="token string">'Mail.ReadWrite'</span><span class="token punctuation">,</span> <span class="token string">'Mail.Send'</span><span class="token punctuation">,</span> <span class="token string">'MailboxSettings.ReadWrite'</span><span class="token punctuation">,</span>
    <span class="token string">'Files.Read.All'</span><span class="token punctuation">,</span> <span class="token string">'Files.ReadWrite.All'</span><span class="token punctuation">,</span> <span class="token string">'Sites.Read.All'</span><span class="token punctuation">,</span> <span class="token string">'Sites.ReadWrite.All'</span><span class="token punctuation">,</span>
    <span class="token string">'Calendars.ReadWrite'</span><span class="token punctuation">,</span> <span class="token string">'Contacts.ReadWrite'</span>
<span class="token punctuation">)</span>

<span class="token keyword">function</span> <span class="token function">Test-HighPrivilegePermission</span> <span class="token punctuation">{</span>
    <span class="token keyword">param</span><span class="token punctuation">(</span>
        <span class="token namespace">[Parameter(Mandatory)]</span><span class="token namespace">[AllowEmptyString()]</span><span class="token namespace">[string]</span> <span class="token variable">$Name</span><span class="token punctuation">,</span>
        <span class="token namespace">[Parameter(Mandatory)]</span><span class="token namespace">[string]</span> <span class="token variable">$Type</span>
    <span class="token punctuation">)</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$Name</span> <span class="token operator">-in</span> <span class="token variable">$highPrivilegeAlways</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token keyword">return</span> <span class="token boolean">$true</span> <span class="token punctuation">}</span>
    <span class="token keyword">return</span> <span class="token variable">$Type</span> <span class="token operator">-eq</span> <span class="token string">'Application'</span> <span class="token operator">-and</span> <span class="token variable">$Name</span> <span class="token operator">-in</span> <span class="token variable">$highPrivilegeAsApplication</span>
<span class="token punctuation">}</span>

<span class="token comment"># Resource service principals are shared across apps (nearly every app points at Microsoft</span>
<span class="token comment"># Graph), so resolve each one once. Without this, a few hundred apps means thousands of calls.</span>
<span class="token variable">$resourceSpCache</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>

<span class="token keyword">function</span> <span class="token function">Get-ResourceServicePrincipal</span> <span class="token punctuation">{</span>
    <span class="token keyword">param</span><span class="token punctuation">(</span><span class="token namespace">[Parameter(Mandatory)]</span><span class="token namespace">[string]</span> <span class="token variable">$ResourceAppId</span><span class="token punctuation">)</span>

    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$resourceSpCache</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token variable">$ResourceAppId</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">return</span> <span class="token variable">$resourceSpCache</span><span class="token punctuation">[</span><span class="token variable">$ResourceAppId</span><span class="token punctuation">]</span>
    <span class="token punctuation">}</span>

    <span class="token variable">$sp</span> = <span class="token variable">$null</span>
    <span class="token keyword">try</span> <span class="token punctuation">{</span>
        <span class="token variable">$sp</span> = <span class="token function">Get-MgServicePrincipal</span> <span class="token operator">-</span><span class="token keyword">Filter</span> <span class="token string">"appId eq '<span class="token variable">$ResourceAppId</span>'"</span> `
            <span class="token operator">-</span>Property <span class="token string">'id,appId,displayName,appRoles,oauth2PermissionScopes'</span> `
            <span class="token operator">-</span>ErrorAction Stop <span class="token punctuation">|</span> <span class="token function">Select-Object</span> <span class="token operator">-</span>First 1
    <span class="token punctuation">}</span>
    <span class="token keyword">catch</span> <span class="token punctuation">{</span>
        <span class="token function">Write-Warning</span> <span class="token string">"Could not resolve resource app <span class="token variable">$ResourceAppId</span> : <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Exception<span class="token punctuation">.</span>Message<span class="token punctuation">)</span></span>"</span>
    <span class="token punctuation">}</span>

    <span class="token variable">$resourceSpCache</span><span class="token punctuation">[</span><span class="token variable">$ResourceAppId</span><span class="token punctuation">]</span> = <span class="token variable">$sp</span>
    <span class="token keyword">return</span> <span class="token variable">$sp</span>
<span class="token punctuation">}</span>

<span class="token comment">#endregion</span>

<span class="token comment">#region Sign-in activity</span>

<span class="token comment"># appId -> lastSignInDateTime. Keyed by appId because that's the only field that ties the</span>
<span class="token comment"># report back to an application; the report's own id is an opaque encoding.</span>
<span class="token variable">$signInByAppId</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>
<span class="token variable">$signInDataAvailable</span> = <span class="token boolean">$false</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$SkipSignInActivity</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token function">Write-Verbose</span> <span class="token string">'Retrieving service principal sign-in activity (beta preview report)...'</span>
    <span class="token keyword">try</span> <span class="token punctuation">{</span>
        <span class="token variable">$uri</span> = <span class="token string">'https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities'</span>
        <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token variable">$uri</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token variable">$response</span> = <span class="token function">Invoke-MgGraphRequest</span> <span class="token operator">-</span>Method GET <span class="token operator">-</span>Uri <span class="token variable">$uri</span> <span class="token operator">-</span>ErrorAction Stop

            <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$entry</span> in <span class="token variable">$response</span><span class="token punctuation">.</span>value<span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token comment"># lastSignInActivity is the roll-up across delegated/app-only and</span>
                <span class="token comment"># client/resource flows: exactly the "is anything still calling this?"</span>
                <span class="token comment"># question. Absent when the SP has no recorded activity at all.</span>
                <span class="token variable">$last</span> = <span class="token variable">$null</span>
                <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$entry</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token string">'lastSignInActivity'</span><span class="token punctuation">)</span> <span class="token operator">-and</span> <span class="token variable">$entry</span><span class="token punctuation">.</span>lastSignInActivity<span class="token punctuation">)</span> <span class="token punctuation">{</span>
                    <span class="token variable">$last</span> = <span class="token variable">$entry</span><span class="token punctuation">.</span>lastSignInActivity<span class="token punctuation">.</span>lastSignInDateTime
                <span class="token punctuation">}</span>
                <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$entry</span><span class="token punctuation">.</span>appId<span class="token punctuation">)</span> <span class="token punctuation">{</span>
                    <span class="token variable">$signInByAppId</span><span class="token punctuation">[</span><span class="token variable">$entry</span><span class="token punctuation">.</span>appId<span class="token punctuation">]</span> = <span class="token variable">$last</span>
                <span class="token punctuation">}</span>
            <span class="token punctuation">}</span>

            <span class="token variable">$uri</span> = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$response</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token string">'@odata.nextLink'</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$response</span><span class="token punctuation">.</span><span class="token string">'@odata.nextLink'</span> <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
        <span class="token punctuation">}</span>

        <span class="token variable">$signInDataAvailable</span> = <span class="token boolean">$true</span>
        <span class="token function">Write-Verbose</span> <span class="token string">"Sign-in activity retrieved for <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$signInByAppId</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> service principals."</span>
    <span class="token punctuation">}</span>
    <span class="token keyword">catch</span> <span class="token punctuation">{</span>
        <span class="token comment"># Most likely: no Entra ID P1/P2, missing AuditLog.Read.All, or a sovereign cloud</span>
        <span class="token comment"># where the preview report doesn't exist. Not fatal: the rest of the report is useful.</span>
        <span class="token function">Write-Warning</span> <span class="token string">"Sign-in activity unavailable, UsageState will be 'Unknown'. <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Exception<span class="token punctuation">.</span>Message<span class="token punctuation">)</span></span>"</span>
        <span class="token function">Write-Warning</span> <span class="token string">'This report requires Entra ID P1/P2 and AuditLog.Read.All.'</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token comment">#endregion</span>

<span class="token comment">#region Service principals for the tenant's own apps</span>

<span class="token comment"># One pass to map appId -> SP. Needed both to know whether an app has an SP at all and to</span>
<span class="token comment"># read what's actually been granted to it.</span>
<span class="token function">Write-Verbose</span> <span class="token string">'Retrieving service principals...'</span>
<span class="token variable">$spByAppId</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>
<span class="token function">Get-MgServicePrincipal</span> <span class="token operator">-</span>All <span class="token operator">-</span>Property <span class="token string">'id,appId,displayName,accountEnabled'</span> <span class="token punctuation">|</span>
    <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span> <span class="token variable">$spByAppId</span><span class="token punctuation">[</span><span class="token variable">$_</span><span class="token punctuation">.</span>AppId<span class="token punctuation">]</span> = <span class="token variable">$_</span> <span class="token punctuation">}</span>
<span class="token function">Write-Verbose</span> <span class="token string">"Found <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$spByAppId</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> service principals."</span>

<span class="token comment">#endregion</span>

<span class="token comment">#region Applications</span>

<span class="token function">Write-Verbose</span> <span class="token string">'Retrieving app registrations...'</span>
<span class="token comment"># Get-MgApplication doesn't return createdDateTime by default, so ask for it explicitly.</span>
<span class="token variable">$applications</span> = <span class="token function">Get-MgApplication</span> <span class="token operator">-</span>All <span class="token operator">-</span>Property @<span class="token punctuation">(</span>
    <span class="token string">'id'</span><span class="token punctuation">,</span> <span class="token string">'appId'</span><span class="token punctuation">,</span> <span class="token string">'displayName'</span><span class="token punctuation">,</span> <span class="token string">'createdDateTime'</span><span class="token punctuation">,</span> <span class="token string">'signInAudience'</span><span class="token punctuation">,</span> <span class="token string">'publisherDomain'</span><span class="token punctuation">,</span>
    <span class="token string">'requiredResourceAccess'</span><span class="token punctuation">,</span> <span class="token string">'passwordCredentials'</span><span class="token punctuation">,</span> <span class="token string">'keyCredentials'</span><span class="token punctuation">,</span> <span class="token string">'notes'</span><span class="token punctuation">,</span> <span class="token string">'tags'</span>
<span class="token punctuation">)</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$IncludeMicrosoftApps</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token variable">$before</span> = <span class="token variable">$applications</span><span class="token punctuation">.</span>Count
    <span class="token variable">$applications</span> = <span class="token variable">$applications</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span>
        <span class="token variable">$_</span><span class="token punctuation">.</span>PublisherDomain <span class="token operator">-notmatch</span> <span class="token string">'(?i)^(microsoft\.com|sharepointonline\.com)$'</span>
    <span class="token punctuation">}</span>
    <span class="token function">Write-Verbose</span> <span class="token string">"Excluded <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$before</span> <span class="token operator">-</span> <span class="token variable">$applications</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> Microsoft-published apps (use -IncludeMicrosoftApps to keep them)."</span>
<span class="token punctuation">}</span>

<span class="token function">Write-Verbose</span> <span class="token string">"Processing <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$applications</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> app registrations..."</span>

<span class="token comment">#endregion</span>

<span class="token variable">$now</span> = <span class="token namespace">[DateTime]</span>::UtcNow
<span class="token variable">$permissionRows</span> = <span class="token namespace">[System.Collections.Generic.List[object]]</span>::new<span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token variable">$results</span> = <span class="token namespace">[System.Collections.Generic.List[object]]</span>::new<span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token variable">$appIndex</span> = 0

<span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$app</span> in <span class="token variable">$applications</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token variable">$appIndex</span><span class="token operator">++</span>
    <span class="token function">Write-Progress</span> <span class="token operator">-</span>Activity <span class="token string">'Inventorying app registrations'</span> `
        <span class="token operator">-</span>Status <span class="token string">"<span class="token variable">$appIndex</span>/<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$applications</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span>: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>DisplayName<span class="token punctuation">)</span></span>"</span> `
        <span class="token operator">-</span>PercentComplete <span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token variable">$appIndex</span> <span class="token operator">/</span> <span class="token namespace">[Math]</span>::Max<span class="token punctuation">(</span><span class="token variable">$applications</span><span class="token punctuation">.</span>Count<span class="token punctuation">,</span> 1<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token operator">*</span> 100<span class="token punctuation">)</span>

    <span class="token variable">$sp</span> = <span class="token variable">$spByAppId</span><span class="token punctuation">[</span><span class="token variable">$app</span><span class="token punctuation">.</span>AppId<span class="token punctuation">]</span>

    <span class="token comment">#region Granted permissions (what the app can actually do)</span>

    <span class="token comment"># Only meaningful if an SP exists: consent is recorded against the SP, not the app.</span>
    <span class="token variable">$grantedAppRoleIds</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>   <span class="token comment"># appRoleId -> $true</span>
    <span class="token variable">$grantedScopes</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>       <span class="token comment"># scope name -> $true</span>

    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$sp</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">try</span> <span class="token punctuation">{</span>
            <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$assignment</span> in <span class="token function">Get-MgServicePrincipalAppRoleAssignment</span> <span class="token operator">-</span>ServicePrincipalId <span class="token variable">$sp</span><span class="token punctuation">.</span>Id <span class="token operator">-</span>All<span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token variable">$grantedAppRoleIds</span><span class="token punctuation">[</span><span class="token variable">$assignment</span><span class="token punctuation">.</span>AppRoleId<span class="token punctuation">]</span> = <span class="token boolean">$true</span>
            <span class="token punctuation">}</span>
        <span class="token punctuation">}</span>
        <span class="token keyword">catch</span> <span class="token punctuation">{</span>
            <span class="token function">Write-Warning</span> <span class="token string">"Could not read app role assignments for '<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>DisplayName<span class="token punctuation">)</span></span>': <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Exception<span class="token punctuation">.</span>Message<span class="token punctuation">)</span></span>"</span>
        <span class="token punctuation">}</span>

        <span class="token keyword">try</span> <span class="token punctuation">{</span>
            <span class="token comment"># Read via the SP's own navigation property rather than Get-MgOauth2PermissionGrant,</span>
            <span class="token comment"># which lives in Microsoft.Graph.Identity.SignIns: not worth a third module</span>
            <span class="token comment"># dependency for one call.</span>
            <span class="token variable">$grantUri</span> = <span class="token string">"https://graph.microsoft.com/v1.0/servicePrincipals/<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$sp</span><span class="token punctuation">.</span>Id<span class="token punctuation">)</span></span>/oauth2PermissionGrants"</span>
            <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token variable">$grantUri</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token variable">$grantResponse</span> = <span class="token function">Invoke-MgGraphRequest</span> <span class="token operator">-</span>Method GET <span class="token operator">-</span>Uri <span class="token variable">$grantUri</span> <span class="token operator">-</span>ErrorAction Stop
                <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$grant</span> in <span class="token variable">$grantResponse</span><span class="token punctuation">.</span>value<span class="token punctuation">)</span> <span class="token punctuation">{</span>
                    <span class="token comment"># scope is a space-delimited string; a grant exists per resource and per</span>
                    <span class="token comment"># consent type (AllPrincipals = admin consent, Principal = a single user).</span>
                    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$grant</span><span class="token punctuation">.</span>scope<span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token keyword">continue</span> <span class="token punctuation">}</span>
                    <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$scope</span> in <span class="token punctuation">(</span><span class="token variable">$grant</span><span class="token punctuation">.</span>scope <span class="token operator">-</span>split <span class="token string">'\s+'</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                        <span class="token variable">$grantedScopes</span><span class="token punctuation">[</span><span class="token variable">$scope</span><span class="token punctuation">]</span> = <span class="token boolean">$true</span>
                    <span class="token punctuation">}</span>
                <span class="token punctuation">}</span>
                <span class="token variable">$grantUri</span> = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$grantResponse</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token string">'@odata.nextLink'</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$grantResponse</span><span class="token punctuation">.</span><span class="token string">'@odata.nextLink'</span> <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
            <span class="token punctuation">}</span>
        <span class="token punctuation">}</span>
        <span class="token keyword">catch</span> <span class="token punctuation">{</span>
            <span class="token function">Write-Warning</span> <span class="token string">"Could not read delegated grants for '<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>DisplayName<span class="token punctuation">)</span></span>': <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Exception<span class="token punctuation">.</span>Message<span class="token punctuation">)</span></span>"</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>

    <span class="token comment">#endregion</span>

    <span class="token comment">#region Requested permissions, resolved to names</span>

    <span class="token variable">$permissions</span> = <span class="token namespace">[System.Collections.Generic.List[object]]</span>::new<span class="token punctuation">(</span><span class="token punctuation">)</span>

    <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$required</span> in @<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>RequiredResourceAccess<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$resourceSp</span> = <span class="token function">Get-ResourceServicePrincipal</span> <span class="token operator">-</span>ResourceAppId <span class="token variable">$required</span><span class="token punctuation">.</span>ResourceAppId
        <span class="token variable">$resourceName</span> = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$resourceSp</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$resourceSp</span><span class="token punctuation">.</span>DisplayName <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token string">"Unknown (<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$required</span><span class="token punctuation">.</span>ResourceAppId<span class="token punctuation">)</span></span>)"</span> <span class="token punctuation">}</span>

        <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$access</span> in @<span class="token punctuation">(</span><span class="token variable">$required</span><span class="token punctuation">.</span>ResourceAccess<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token comment"># 'Role'  = application permission (app-only, no user present).</span>
            <span class="token comment"># 'Scope' = delegated permission (acts on behalf of a signed-in user).</span>
            <span class="token variable">$isAppRole</span> = <span class="token variable">$access</span><span class="token punctuation">.</span><span class="token function">Type</span> <span class="token operator">-eq</span> <span class="token string">'Role'</span>
            <span class="token variable">$permissionName</span> = <span class="token variable">$null</span>
            <span class="token variable">$isGranted</span> = <span class="token boolean">$false</span>

            <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$resourceSp</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$isAppRole</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                    <span class="token variable">$role</span> = <span class="token variable">$resourceSp</span><span class="token punctuation">.</span>AppRoles <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>Id <span class="token operator">-eq</span> <span class="token variable">$access</span><span class="token punctuation">.</span>Id <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">Select-Object</span> <span class="token operator">-</span>First 1
                    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$role</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$permissionName</span> = <span class="token variable">$role</span><span class="token punctuation">.</span>Value <span class="token punctuation">}</span>
                    <span class="token variable">$isGranted</span> = <span class="token variable">$grantedAppRoleIds</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token variable">$access</span><span class="token punctuation">.</span>Id<span class="token punctuation">)</span>
                <span class="token punctuation">}</span>
                <span class="token keyword">else</span> <span class="token punctuation">{</span>
                    <span class="token variable">$scope</span> = <span class="token variable">$resourceSp</span><span class="token punctuation">.</span>Oauth2PermissionScopes <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>Id <span class="token operator">-eq</span> <span class="token variable">$access</span><span class="token punctuation">.</span>Id <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">Select-Object</span> <span class="token operator">-</span>First 1
                    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$scope</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$permissionName</span> = <span class="token variable">$scope</span><span class="token punctuation">.</span>Value <span class="token punctuation">}</span>
                    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$permissionName</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$isGranted</span> = <span class="token variable">$grantedScopes</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token variable">$permissionName</span><span class="token punctuation">)</span> <span class="token punctuation">}</span>
                <span class="token punctuation">}</span>
            <span class="token punctuation">}</span>

            <span class="token comment"># Fall back to the GUID so an unresolvable permission is still visible rather</span>
            <span class="token comment"># than silently dropped from the report.</span>
            <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$permissionName</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$permissionName</span> = <span class="token string">"(<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$access</span><span class="token punctuation">.</span>Id<span class="token punctuation">)</span></span>)"</span> <span class="token punctuation">}</span>

            <span class="token variable">$permissions</span><span class="token punctuation">.</span>Add<span class="token punctuation">(</span><span class="token namespace">[pscustomobject]</span>@<span class="token punctuation">{</span>
                ResourceName   = <span class="token variable">$resourceName</span>
                ResourceAppId  = <span class="token variable">$required</span><span class="token punctuation">.</span>ResourceAppId
                PermissionName = <span class="token variable">$permissionName</span>
                PermissionType = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$isAppRole</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token string">'Application'</span> <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token string">'Delegated'</span> <span class="token punctuation">}</span>
                IsGranted      = <span class="token variable">$isGranted</span>
            <span class="token punctuation">}</span><span class="token punctuation">)</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>

    <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$permission</span> in <span class="token variable">$permissions</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$permissionRows</span><span class="token punctuation">.</span>Add<span class="token punctuation">(</span><span class="token namespace">[pscustomobject]</span>@<span class="token punctuation">{</span>
            AppDisplayName = <span class="token variable">$app</span><span class="token punctuation">.</span>DisplayName
            AppId          = <span class="token variable">$app</span><span class="token punctuation">.</span>AppId
            ResourceName   = <span class="token variable">$permission</span><span class="token punctuation">.</span>ResourceName
            PermissionName = <span class="token variable">$permission</span><span class="token punctuation">.</span>PermissionName
            PermissionType = <span class="token variable">$permission</span><span class="token punctuation">.</span>PermissionType
            IsGranted      = <span class="token variable">$permission</span><span class="token punctuation">.</span>IsGranted
        <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">}</span>

    <span class="token comment">#endregion</span>

    <span class="token comment">#region Usage verdict</span>

    <span class="token variable">$lastSignIn</span> = <span class="token variable">$null</span>
    <span class="token variable">$daysSinceSignIn</span> = <span class="token variable">$null</span>

    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$signInByAppId</span><span class="token punctuation">.</span>ContainsKey<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>AppId<span class="token punctuation">)</span> <span class="token operator">-and</span> <span class="token variable">$signInByAppId</span><span class="token punctuation">[</span><span class="token variable">$app</span><span class="token punctuation">.</span>AppId<span class="token punctuation">]</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$lastSignIn</span> = <span class="token namespace">[DateTime]</span><span class="token variable">$signInByAppId</span><span class="token punctuation">[</span><span class="token variable">$app</span><span class="token punctuation">.</span>AppId<span class="token punctuation">]</span>
        <span class="token variable">$daysSinceSignIn</span> = <span class="token namespace">[Math]</span>::Round<span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token variable">$now</span> <span class="token operator">-</span> <span class="token variable">$lastSignIn</span><span class="token punctuation">.</span>ToUniversalTime<span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">.</span>TotalDays<span class="token punctuation">)</span>
    <span class="token punctuation">}</span>

    <span class="token variable">$usageState</span> =
        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$sp</span><span class="token punctuation">)</span>                          <span class="token punctuation">{</span> <span class="token string">'NoServicePrincipal'</span> <span class="token punctuation">}</span>  <span class="token comment"># can't sign in here at all</span>
        <span class="token keyword">elseif</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$signInDataAvailable</span><span class="token punctuation">)</span>     <span class="token punctuation">{</span> <span class="token string">'Unknown'</span> <span class="token punctuation">}</span>             <span class="token comment"># report didn't run</span>
        <span class="token keyword">elseif</span> <span class="token punctuation">(</span><span class="token variable">$null</span> <span class="token operator">-eq</span> <span class="token variable">$lastSignIn</span><span class="token punctuation">)</span>         <span class="token punctuation">{</span> <span class="token string">'NeverSignedIn'</span> <span class="token punctuation">}</span>       <span class="token comment"># no activity on record</span>
        <span class="token keyword">elseif</span> <span class="token punctuation">(</span><span class="token variable">$daysSinceSignIn</span> <span class="token operator">-le</span> <span class="token variable">$UnusedAfterDays</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token string">'Active'</span> <span class="token punctuation">}</span>
        <span class="token keyword">else</span>                                   <span class="token punctuation">{</span> <span class="token string">'Stale'</span> <span class="token punctuation">}</span>

    <span class="token comment">#endregion</span>

    <span class="token comment">#region Credentials</span>

    <span class="token variable">$credentials</span> = @<span class="token punctuation">(</span>@<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>PasswordCredentials<span class="token punctuation">)</span> <span class="token operator">+</span> @<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>KeyCredentials<span class="token punctuation">)</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token variable">$activeCredentials</span> = @<span class="token punctuation">(</span><span class="token variable">$credentials</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>EndDateTime <span class="token operator">-and</span> <span class="token namespace">[DateTime]</span><span class="token variable">$_</span><span class="token punctuation">.</span>EndDateTime <span class="token operator">-gt</span> <span class="token variable">$now</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token variable">$nextExpiry</span> = <span class="token variable">$activeCredentials</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> EndDateTime <span class="token punctuation">|</span> <span class="token function">Select-Object</span> <span class="token operator">-</span>First 1

    <span class="token comment">#endregion</span>

    <span class="token variable">$createdDateTime</span> = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>CreatedDateTime<span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token namespace">[DateTime]</span><span class="token variable">$app</span><span class="token punctuation">.</span>CreatedDateTime <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>

    <span class="token variable">$results</span><span class="token punctuation">.</span>Add<span class="token punctuation">(</span><span class="token namespace">[pscustomobject]</span>@<span class="token punctuation">{</span>
        DisplayName               = <span class="token variable">$app</span><span class="token punctuation">.</span>DisplayName
        AppId                     = <span class="token variable">$app</span><span class="token punctuation">.</span>AppId
        ObjectId                  = <span class="token variable">$app</span><span class="token punctuation">.</span>Id
        CreatedDateTime           = <span class="token variable">$createdDateTime</span>
        AgeInDays                 = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$createdDateTime</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token namespace">[Math]</span>::Round<span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token variable">$now</span> <span class="token operator">-</span> <span class="token variable">$createdDateTime</span><span class="token punctuation">.</span>ToUniversalTime<span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">.</span>TotalDays<span class="token punctuation">)</span> <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
        UsageState                = <span class="token variable">$usageState</span>
        LastSignInDateTime        = <span class="token variable">$lastSignIn</span>
        DaysSinceLastSignIn       = <span class="token variable">$daysSinceSignIn</span>
        ServicePrincipalId        = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$sp</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$sp</span><span class="token punctuation">.</span>Id <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
        AccountEnabled            = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$sp</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$sp</span><span class="token punctuation">.</span>AccountEnabled <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
        SignInAudience            = <span class="token variable">$app</span><span class="token punctuation">.</span>SignInAudience
        PublisherDomain           = <span class="token variable">$app</span><span class="token punctuation">.</span>PublisherDomain
        PermissionCount           = <span class="token variable">$permissions</span><span class="token punctuation">.</span>Count
        GrantedPermissionCount    = @<span class="token punctuation">(</span><span class="token variable">$permissions</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>IsGranted <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span>Count
        ApplicationPermissions    = <span class="token punctuation">(</span><span class="token variable">$permissions</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionType <span class="token operator">-eq</span> <span class="token string">'Application'</span> <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionName <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> <span class="token operator">-</span>Unique<span class="token punctuation">)</span> <span class="token operator">-join</span> <span class="token string">'; '</span>
        DelegatedPermissions      = <span class="token punctuation">(</span><span class="token variable">$permissions</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionType <span class="token operator">-eq</span> <span class="token string">'Delegated'</span> <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionName <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> <span class="token operator">-</span>Unique<span class="token punctuation">)</span> <span class="token operator">-join</span> <span class="token string">'; '</span>
        GrantedPermissions        = <span class="token punctuation">(</span><span class="token variable">$permissions</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>IsGranted <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionName <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> <span class="token operator">-</span>Unique<span class="token punctuation">)</span> <span class="token operator">-join</span> <span class="token string">'; '</span>
        HasHighPrivilegePermission = <span class="token namespace">[bool]</span>@<span class="token punctuation">(</span><span class="token variable">$permissions</span> <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span>
            <span class="token variable">$_</span><span class="token punctuation">.</span>IsGranted <span class="token operator">-and</span> <span class="token punctuation">(</span><span class="token function">Test-HighPrivilegePermission</span> <span class="token operator">-</span>Name <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionName <span class="token operator">-</span><span class="token function">Type</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionType<span class="token punctuation">)</span>
        <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span>Count
        SecretCount               = @<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>PasswordCredentials<span class="token punctuation">)</span><span class="token punctuation">.</span>Where<span class="token punctuation">(</span><span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span>Count
        CertificateCount          = @<span class="token punctuation">(</span><span class="token variable">$app</span><span class="token punctuation">.</span>KeyCredentials<span class="token punctuation">)</span><span class="token punctuation">.</span>Where<span class="token punctuation">(</span><span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span>Count
        HasValidCredential        = <span class="token variable">$activeCredentials</span><span class="token punctuation">.</span>Count <span class="token operator">-gt</span> 0
        NextCredentialExpiry      = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$nextExpiry</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token namespace">[DateTime]</span><span class="token variable">$nextExpiry</span><span class="token punctuation">.</span>EndDateTime <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token variable">$null</span> <span class="token punctuation">}</span>
        Permissions               = <span class="token variable">$permissions</span>   <span class="token comment"># kept for pipeline use; dropped on CSV export</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token function">Write-Progress</span> <span class="token operator">-</span>Activity <span class="token string">'Inventorying app registrations'</span> <span class="token operator">-</span>Completed

<span class="token comment">#region Output</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$CsvPath</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token comment"># Permissions is a nested object collection and would export as a type name.</span>
    <span class="token variable">$results</span> <span class="token punctuation">|</span>
        <span class="token function">Select-Object</span> <span class="token operator">-</span>ExcludeProperty Permissions <span class="token punctuation">|</span>
        <span class="token function">Export-Csv</span> <span class="token operator">-</span>Path <span class="token variable">$CsvPath</span> <span class="token operator">-</span>NoTypeInformation <span class="token operator">-</span>Encoding UTF8
    <span class="token function">Write-Verbose</span> <span class="token string">"Wrote app inventory to <span class="token variable">$CsvPath</span>"</span>
<span class="token punctuation">}</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$PermissionCsvPath</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token variable">$permissionRows</span> <span class="token punctuation">|</span> <span class="token function">Export-Csv</span> <span class="token operator">-</span>Path <span class="token variable">$PermissionCsvPath</span> <span class="token operator">-</span>NoTypeInformation <span class="token operator">-</span>Encoding UTF8
    <span class="token function">Write-Verbose</span> <span class="token string">"Wrote <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$permissionRows</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> permission rows to <span class="token variable">$PermissionCsvPath</span>"</span>
<span class="token punctuation">}</span>

<span class="token variable">$summary</span> = <span class="token variable">$results</span> <span class="token punctuation">|</span> <span class="token function">Group-Object</span> UsageState <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> Name
<span class="token function">Write-Verbose</span> <span class="token string">"Summary: $((<span class="token variable">$summary</span> | ForEach-Object { "</span>$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Name<span class="token punctuation">)</span>=$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span><span class="token string">" }) -join ', ')"</span>

<span class="token variable">$results</span>

<span class="token comment">#endregion</span></code></pre>
<h2>Reading the result</h2>
<p>The report is one object per app, so pipe it however you think. But the value is in a few columns read together.</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>What it's telling you</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>UsageState</code></td>
<td><code>Stale</code> (no sign-in past your cutoff), <code>NeverSignedIn</code> (no activity on record), <code>NoServicePrincipal</code> (an app object with no SP, so it can't sign in here at all), or <code>Unknown</code> (the licensed report didn't run). <code>Active</code> is the only one you don't need to look at.</td>
</tr>
<tr>
<td><code>HasHighPrivilegePermission</code></td>
<td>The app holds a tenant-wide write scope, or a mailbox/file application permission, <strong>and it's actually granted</strong>. This is the triage flag, not a verdict.</td>
</tr>
<tr>
<td><code>GrantedPermissions</code> vs <code>ApplicationPermissions</code></td>
<td>What the app <em>got</em> versus what it <em>asked for</em>. A short granted list under a long requested list is an app that over-asked and was rightly reined in. The reverse is the one to worry about.</td>
</tr>
<tr>
<td><code>HasValidCredential</code> + <code>NextCredentialExpiry</code></td>
<td>A stale app with a live secret is a standing key to nowhere. A stale app with an <em>expired</em> credential is a strong second signal that nothing is using it.</td>
</tr>
</tbody>
</table>
<p>The single query that earns the whole script is the cleanup shortlist: apps that are stale but still dangerous.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment"># Stale apps that still hold high-privilege permissions. Start your review here.</span>
<span class="token punctuation">.</span>\EntraID-Discover-AppRegistrations<span class="token punctuation">.</span>ps1 <span class="token punctuation">|</span>
    <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>UsageState <span class="token operator">-eq</span> <span class="token string">'Stale'</span> <span class="token operator">-and</span> <span class="token variable">$_</span><span class="token punctuation">.</span>HasHighPrivilegePermission <span class="token punctuation">}</span> <span class="token punctuation">|</span>
    <span class="token function">Sort-Object</span> DaysSinceLastSignIn <span class="token operator">-</span>Descending <span class="token punctuation">|</span>
    <span class="token function">Format-Table</span> DisplayName<span class="token punctuation">,</span> DaysSinceLastSignIn<span class="token punctuation">,</span> GrantedPermissions</code></pre>
<p>Nothing calling it in months, and it can still rewrite your directory. That's the row you take to the app owner, if you can still find one.</p>
<p>For pivoting the other way, &quot;show me every app that holds <code>Mail.ReadWrite</code>,&quot; export the long-format permission list and filter that:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token punctuation">.</span>\EntraID-Discover-AppRegistrations<span class="token punctuation">.</span>ps1 <span class="token operator">-</span>PermissionCsvPath <span class="token punctuation">.</span>\perms<span class="token punctuation">.</span>csv
<span class="token function">Import-Csv</span> <span class="token punctuation">.</span>\perms<span class="token punctuation">.</span>csv <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>PermissionName <span class="token operator">-eq</span> <span class="token string">'Mail.ReadWrite'</span> <span class="token operator">-and</span> <span class="token variable">$_</span><span class="token punctuation">.</span>IsGranted <span class="token operator">-eq</span> <span class="token string">'True'</span> <span class="token punctuation">}</span></code></pre>
<p>No Entra Premium, or you only want the free signals? Run it with <code>-SkipSignInActivity</code>. You lose the <code>UsageState</code> verdict, but you keep every creation date, every requested-versus-granted permission, and every credential expiry, which is still enough to find the four-year-old app holding <code>Application.ReadWrite.All</code>. That split, which answers sit behind P1/P2 and which you can get for nothing, is mapped out per feature in <a href="/licensing.html">Licensing</a>.</p>
<h2>From list to action</h2>
<p>Don't mass-delete app registrations. Deleting an app that turns out to run payroll at 2am on the last day of the month is a bad afternoon, and the sign-in report isn't backfilled, so a fresh <code>NeverSignedIn</code> might just mean &quot;no activity <em>recorded yet</em>.&quot;</p>
<p>Disable first, the same pattern that works for stale users. Set <code>accountEnabled</code> to <code>false</code> on the app's <strong>service principal</strong> (not the application object) and the app can't authenticate, instantly and reversibly. Give it a few weeks. If nothing breaks and nobody shouts, delete it. The export you saved is your worklist now and your audit trail later: this is the before, the disabled apps are the after, and the ones still standing are the ones you deliberately kept.</p>
<p>And before you disable, use the columns you have to ask the owner one real question: &quot;this app was created in 2022, hasn't signed in for 300 days, and can read every mailbox in the tenant, do we still need it?&quot; Most of the time the answer is a sheepish no. That last clause is the one that lands, so it pays to get it right: <a href="/least-privilege.html">Least Privilege</a> spells out what a given permission actually reaches, and flags the ones that reach the whole tenant.</p>
<h2>Prove it, don't assume it</h2>
<p>You almost certainly have more app registrations than people who remember creating them, and some of them can do far more than anything they're still used for. That's not a failure of process, it's just what happens when the easiest identity to create is also the one nobody's job it is to retire.</p>
<p>Run the inventory once and you'll have your shortlist by the end of the coffee. Run it on a schedule and you turn &quot;we review our app registrations&quot; from a sentence in a policy into a CSV you can hand an auditor. Least privilege isn't the day you consented carefully. It's the day you went back and checked.</p>
<p>Want this folded into a recurring review, or into a broader baseline assessment of your tenant's non-human identities? <a href="/#contact">Let me know</a>.</p>
]]></content>
  </entry>
  <entry>
    <title>Your tenant is full of guest accounts that never signed in. Here&#39;s how to find them</title>
    <link href="https://skybytes.io/blog/guests-never-signed-in/"/>
    <updated>2026-07-15T00:00:00Z</updated>
    <id>https://skybytes.io/blog/guests-never-signed-in/</id>
    <summary>That guest you invited in 2023 still has access to your Teams and SharePoint. This script finds every stale guest, including the ones who never accepted, and shows you which to block.</summary>
    <content type="html"><![CDATA[<p>Somewhere in your tenant is a guest account from a project that wrapped up two years ago. Someone from a partner, a freelancer, an accountant: invited for one SharePoint site, added to one Team, and never thought about again. The project ended. The account did not.</p>
<p>Guests are the easiest thing to add in Microsoft 365 and the easiest thing to forget. Every one of them is an external identity with a foothold in your tenant, and the pile only ever grows in one direction. <strong>Nobody offboards a guest.</strong></p>
<h2>Why inactive guest accounts are a real risk</h2>
<p>A guest account isn't a harmless placeholder. It can hold membership in Teams, access to SharePoint sites, and permissions on shared content, all authenticated from an identity you don't control and can't enforce a password policy on. When that external account gets phished, or the partner company has a breach, the blast radius is <em>your</em> data, reached through a door you left open.</p>
<p>For anyone thinking in framework terms, this sits right on top of the parts auditors actually poke at: <strong>management of inactive accounts, least privilege, and periodic access reviews.</strong> BIO2 and NIS2 both expect you to know who has access and to pull it when it's no longer needed. &quot;We invite guests as needed&quot; is a policy. The auditor wants the list of guests who haven't signed in since 2023, and proof you did something about them. Let's build that list.</p>
<h2>How Entra tells you a guest went dark</h2>
<p>The signal lives in one property: <code>signInActivity</code>. It carries three timestamps worth knowing:</p>
<ul>
<li><code>lastSuccessfulSignInDateTime</code> is the last time the account <em>actually got in</em>. This is the one you want. It's been available since December 2023, and it isn't backfilled, so older accounts may be blank here.</li>
<li><code>lastSignInDateTime</code> is the last interactive sign-in <em>attempt</em>, successful or not.</li>
<li><code>lastNonInteractiveSignInDateTime</code> covers token refreshes and background client activity.</li>
</ul>
<p>There's one behaviour that trips people up: <strong><code>signInActivity</code> is not returned at all for an account that has never signed in.</strong> So a blank isn't a bug, it's a category. A guest with no sign-in activity either never accepted the invite or never used it, which for our purposes is the worst kind of stale. When the property is empty, we fall back to <code>createdDateTime</code>: invited this long ago, never once seen.</p>
<blockquote>
<p><strong>Mind the licence.</strong> The sign-in timestamps in <code>signInActivity</code> require an <strong>Entra ID P1 or P2 licence</strong> and the <strong><code>AuditLog.Read.All</code></strong> permission. Without both, Graph returns that field empty for <em>every</em> user, and suddenly your whole tenant looks like it never signed in. If every guest comes back as &quot;never signed in,&quot; don't panic and mass-disable: check your licensing and scopes first.</p>
</blockquote>
<h2>No Entra Premium? You still catch the worst offenders</h2>
<p>Here's the good news for tenants without P1/P2. The sign-in <em>dates</em> need the licence, but two of the most useful signals don't. <code>createdDateTime</code> (when the guest was invited) and <code>externalUserState</code> (whether they ever accepted) are ordinary directory properties that come back on any tenant, free.</p>
<p>That means even with no Entra Premium anywhere in sight, you can reliably surface the single most removable thing in your directory: <strong>guests invited months ago who never accepted the invite.</strong> A guest sitting at <code>PendingAcceptance</code> for 400 days was never in play. You don't need a sign-in timestamp to know that account is dead weight. What you lose without the licence is the subtler distinction between &quot;accepted and active&quot; and &quot;accepted and dormant.&quot; The obvious junk still shows up.</p>
<p>The script leans into this. It checks once whether <code>signInActivity</code> is actually coming back, and only then adds the licensed columns (<code>LastSuccessfulSignIn</code>, <code>LastInteractiveSignIn</code>, and a <code>NeverSignedIn</code> flag) to the export. On a Premium tenant your CSV carries the sign-in evidence; on a tenant without it those columns are absent entirely, so nobody mistakes an empty field for a confirmed &quot;never signed in.&quot; Same script, honest output either way.</p>
<h2>The measurement</h2>
<p><img src="guest-decision-flow.svg" alt="Flowchart of the script's logic: every guest is reduced to one last-seen date (the last successful sign-in, or the invite date if they never signed in), tested against your cutoff of 90 days, and stale guests are sorted into three buckets, never accepted, accepted but never used, and once-active but dormant, before being blocked and later deleted."></p>
<p>Read-only, no changes to anything. Pull every guest, work out when each was last genuinely seen, and keep the ones past your cutoff. Note that we filter on <code>userType</code>, not on <code>signInActivity</code>. The sign-in field can't be combined with other filters, so we grab it with <code>-Property</code> and do the date maths ourselves.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment">&lt;#
.SYNOPSIS
    Finds stale Entra ID guest accounts and reports which ones to block.

.DESCRIPTION
    Pulls every guest user, works out when each was last genuinely seen (last
    successful sign-in, or the invite date when a guest never signed in), and
    exports the accounts past a chosen inactivity threshold. Read-only: it
    reports, it changes nothing. When the tenant is licensed for it, the export
    gains extra sign-in columns; without the licence those columns are simply
    left off, so the report never lies about data it doesn't have.

.NOTES
    Requires the Microsoft.Graph.Authentication and Microsoft.Graph.Users modules.
    signInActivity dates need an Entra ID P1/P2 licence and AuditLog.Read.All.
    The invite date (createdDateTime) and acceptance state (externalUserState)
    work on any tenant, so pending, never-accepted guests surface without a licence.
#></span>

<span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token string">"User.Read.All"</span><span class="token punctuation">,</span> <span class="token string">"AuditLog.Read.All"</span>

<span class="token variable">$DaysInactive</span> = 90
<span class="token variable">$cutoff</span> = <span class="token punctuation">(</span><span class="token function">Get-Date</span><span class="token punctuation">)</span><span class="token punctuation">.</span>AddDays<span class="token punctuation">(</span><span class="token operator">-</span><span class="token variable">$DaysInactive</span><span class="token punctuation">)</span>

<span class="token comment"># 1) Every guest, with sign-in activity and the date they were invited</span>
<span class="token variable">$props</span> = <span class="token string">"id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime,externalUserState,signInActivity"</span>
<span class="token variable">$guests</span> = <span class="token function">Get-MgUser</span> <span class="token operator">-</span>All <span class="token operator">-</span><span class="token keyword">Filter</span> <span class="token string">"userType eq 'Guest'"</span> <span class="token operator">-</span>Property <span class="token variable">$props</span>

<span class="token comment"># Is sign-in activity actually coming back? It stays empty on tenants without an</span>
<span class="token comment"># Entra ID P1/P2 licence. Decide once, so every row gets the same set of columns.</span>
<span class="token variable">$hasSignInData</span> = <span class="token namespace">[bool]</span><span class="token punctuation">(</span><span class="token variable">$guests</span><span class="token punctuation">.</span>SignInActivity <span class="token punctuation">|</span> <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>

<span class="token comment"># 2) Decide who is stale. No signInActivity means never signed in, so fall back to the invite date.</span>
<span class="token variable">$report</span> = <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$g</span> in <span class="token variable">$guests</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>

    <span class="token variable">$lastSeen</span> = <span class="token variable">$g</span><span class="token punctuation">.</span>SignInActivity<span class="token punctuation">.</span>LastSuccessfulSignInDateTime
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$lastSeen</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token variable">$lastSeen</span> = <span class="token variable">$g</span><span class="token punctuation">.</span>SignInActivity<span class="token punctuation">.</span>LastSignInDateTime <span class="token punctuation">}</span>

    <span class="token comment"># Reference date: last real sign-in, or when the account was created if it never signed in</span>
    <span class="token variable">$reference</span> = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$lastSeen</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token namespace">[datetime]</span><span class="token variable">$lastSeen</span> <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token namespace">[datetime]</span><span class="token variable">$g</span><span class="token punctuation">.</span>CreatedDateTime <span class="token punctuation">}</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$reference</span> <span class="token operator">-ge</span> <span class="token variable">$cutoff</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token keyword">continue</span> <span class="token punctuation">}</span>   <span class="token comment"># still active within the window, skip</span>

    <span class="token comment"># Base columns work on any tenant, licence or not</span>
    <span class="token variable">$row</span> = <span class="token namespace">[ordered]</span>@<span class="token punctuation">{</span>
        Id                = <span class="token variable">$g</span><span class="token punctuation">.</span>Id   <span class="token comment"># the stable handle for actions; guest UPNs are awkward (#EXT#)</span>
        DisplayName       = <span class="token variable">$g</span><span class="token punctuation">.</span>DisplayName
        UserPrincipalName = <span class="token variable">$g</span><span class="token punctuation">.</span>UserPrincipalName
        InviteState       = <span class="token variable">$g</span><span class="token punctuation">.</span>ExternalUserState   <span class="token comment"># PendingAcceptance means never even accepted</span>
        InvitedDaysAgo    = <span class="token namespace">[int]</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">Get-Date</span><span class="token punctuation">)</span> <span class="token operator">-</span> <span class="token namespace">[datetime]</span><span class="token variable">$g</span><span class="token punctuation">.</span>CreatedDateTime<span class="token punctuation">)</span><span class="token punctuation">.</span>TotalDays
        AccountEnabled    = <span class="token variable">$g</span><span class="token punctuation">.</span>AccountEnabled
        InactiveDays      = <span class="token namespace">[int]</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">Get-Date</span><span class="token punctuation">)</span> <span class="token operator">-</span> <span class="token variable">$reference</span><span class="token punctuation">)</span><span class="token punctuation">.</span>TotalDays
    <span class="token punctuation">}</span>

    <span class="token comment"># Licensed extras: add the real sign-in evidence as separate columns, only when it exists</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$hasSignInData</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token variable">$row</span><span class="token punctuation">[</span><span class="token string">'NeverSignedIn'</span><span class="token punctuation">]</span>        = <span class="token namespace">[bool]</span><span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$lastSeen</span><span class="token punctuation">)</span>
        <span class="token variable">$row</span><span class="token punctuation">[</span><span class="token string">'LastSuccessfulSignIn'</span><span class="token punctuation">]</span> = <span class="token variable">$g</span><span class="token punctuation">.</span>SignInActivity<span class="token punctuation">.</span>LastSuccessfulSignInDateTime
        <span class="token variable">$row</span><span class="token punctuation">[</span><span class="token string">'LastInteractiveSignIn'</span><span class="token punctuation">]</span> = <span class="token variable">$g</span><span class="token punctuation">.</span>SignInActivity<span class="token punctuation">.</span>LastSignInDateTime
    <span class="token punctuation">}</span>

    <span class="token namespace">[pscustomobject]</span><span class="token variable">$row</span>
<span class="token punctuation">}</span>

<span class="token comment"># 3) Worst offenders on top, and keep the evidence</span>
<span class="token variable">$report</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> InactiveDays <span class="token operator">-</span>Descending <span class="token punctuation">|</span> <span class="token function">Format-Table</span> <span class="token operator">-</span>AutoSize
<span class="token variable">$report</span> <span class="token punctuation">|</span> <span class="token function">Export-Csv</span> <span class="token punctuation">.</span>\inactive-guests<span class="token punctuation">.</span>csv <span class="token operator">-</span>NoTypeInformation <span class="token operator">-</span>Encoding UTF8</code></pre>
<h2>Reading the result</h2>
<p>Two columns tell most of the story:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>What it's telling you</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>InviteState = PendingAcceptance</code></td>
<td>Invited but <strong>never accepted</strong>. Dead weight from day one, and the safest thing in your tenant to remove. Works on any tenant.</td>
</tr>
<tr>
<td><code>NeverSignedIn = True</code></td>
<td>Accepted the invite (or the state is unknown) but never actually used the account. Invited &quot;just in case,&quot; never in play. <em>Licensed column.</em></td>
</tr>
<tr>
<td><code>InactiveDays</code> (high, <code>NeverSignedIn = False</code>)</td>
<td>A real, once-active guest who has gone quiet. This is where a short &quot;still needed?&quot; email to the sponsor pays off.</td>
</tr>
</tbody>
</table>
<p>The <code>NeverSignedIn</code>, <code>LastSuccessfulSignIn</code>, and <code>LastInteractiveSignIn</code> columns only appear when your tenant is licensed for sign-in data. If they're missing from your CSV, that's your answer on the licence, and <code>InviteState</code> plus <code>InvitedDaysAgo</code> are still doing honest work.</p>
<p>One nuance so you read it honestly: <code>lastSignInDateTime</code> reflects sign-ins to <strong>your</strong> directory and resources. A B2B guest can be busy in their own tenant and still show up here as inactive, which is exactly right, because it means they aren't touching <em>your</em> stuff.</p>
<h2>From list to action</h2>
<p>Disable before you delete. Blocking sign-in is instantly reversible; if someone shouts, you flip one property back. Deletion is a 30-day clock. And because these two steps land weeks apart, drive both from the CSV you exported, not from <code>$report</code> in memory, which is gone the moment you close the window. The export is your worklist, and later your audit trail.</p>
<p>Step one, block sign-in on everything in the list:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment"># Fresh session, write scope only. Blocking needs nothing more than this.</span>
<span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token string">"User.ReadWrite.All"</span>

<span class="token comment"># Read back the list you exported earlier, so this runs in any session.</span>
<span class="token variable">$report</span> = <span class="token function">Import-Csv</span> <span class="token punctuation">.</span>\inactive-guests<span class="token punctuation">.</span>csv

<span class="token comment"># Eyeball it. Happy? Block sign-in on every account.</span>
<span class="token comment"># Target by Id, not UPN: guest UPNs carry the #EXT# format and are easy to fumble.</span>
<span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$g</span> in <span class="token variable">$report</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token function">Update-MgUser</span> <span class="token operator">-</span>UserId <span class="token variable">$g</span><span class="token punctuation">.</span>Id <span class="token operator">-</span>AccountEnabled:<span class="token boolean">$false</span>
    <span class="token function">Write-Host</span> <span class="token string">"Blocked: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$g</span><span class="token punctuation">.</span>UserPrincipalName<span class="token punctuation">)</span></span>"</span>
<span class="token punctuation">}</span></code></pre>
<p>Now give it a couple of weeks. Nobody complained, nothing broke? Come back to the same CSV and remove the accounts that were never real to begin with, the ones that never accepted or have sat dark for months. The rest stay blocked, your safety net:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment"># Weeks later, a fresh session. Same export, same write scope.</span>
<span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token string">"User.ReadWrite.All"</span>

<span class="token comment"># Remove only the clear-cut cases; leave everything else blocked but recoverable.</span>
<span class="token function">Import-Csv</span> <span class="token punctuation">.</span>\inactive-guests<span class="token punctuation">.</span>csv <span class="token punctuation">|</span>
    <span class="token function">Where-Object</span> <span class="token punctuation">{</span> <span class="token variable">$_</span><span class="token punctuation">.</span>InviteState <span class="token operator">-eq</span> <span class="token string">'PendingAcceptance'</span> <span class="token operator">-or</span> <span class="token namespace">[int]</span><span class="token variable">$_</span><span class="token punctuation">.</span>InactiveDays <span class="token operator">-gt</span> 180 <span class="token punctuation">}</span> <span class="token punctuation">|</span>
    <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span>
        <span class="token function">Remove-MgUser</span> <span class="token operator">-</span>UserId <span class="token variable">$_</span><span class="token punctuation">.</span>Id
        <span class="token function">Write-Host</span> <span class="token string">"Removed: <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$_</span><span class="token punctuation">.</span>UserPrincipalName<span class="token punctuation">)</span></span>"</span>
    <span class="token punctuation">}</span></code></pre>
<p>Blocked, then deleted, is a clean and defensible audit trail. The export is your before, the disabled accounts are your after, and the ones still standing are the ones you deliberately kept.</p>
<h2>Why not just use Microsoft's built-in report?</h2>
<p>By now you might be wondering why you're running a script at all. Fair question. Entra does ship an inactive guest insights report, and Access Reviews can even disable and delete stale guests for you on a schedule. It's genuinely good tooling. It's also parked on the most expensive shelf in the store: the inactive guest report and the inactive-user Access Reviews need a <strong>Microsoft Entra ID Governance</strong> or <strong>Entra Suite</strong> licence, not the P1/P2 most tenants already own. And since January 2026, running a guest access review meters per guest through the Entra ID Governance for Guests add-on, so the tidy automation arrives with a line on your Azure bill.</p>
<p>The script asks for a lot less. The sign-in dates it reads come with plain <strong>P1/P2</strong>, and the never-accepted detection costs nothing at all. You get the same core answer, &quot;which guests are dead weight,&quot; without buying the top SKU to find out. If you already own Governance, use the built-in reviews for the recurring workflow by all means. But you should never have to upgrade a licence just to answer a question your directory already knows.</p>
<h2>From measurement to assurance</h2>
<p>Running this once buys you a cleanup. The real win is repetition, because the guest pile refills the moment you look away, as every new project invites a few more. So schedule the measurement, wire an <a href="/#contact">Access Review</a> onto your guests, or at minimum make &quot;does this guest still need to be here?&quot; a recurring question with a CSV to answer it. Policy says you manage inactive accounts. The export proves you actually do.</p>
<p>Want this baked into a periodic access review, or folded into a broader baseline assessment of your tenant? <a href="/#contact">Let me know</a>.</p>
]]></content>
  </entry>
  <entry>
    <title>The offboarding step everyone forgets: meeting ownership</title>
    <link href="https://skybytes.io/blog/offboarding-meeting-ownership/"/>
    <updated>2026-07-08T00:00:00Z</updated>
    <id>https://skybytes.io/blog/offboarding-meeting-ownership/</id>
    <summary>Exchange Online finally lets admins transfer meeting ownership with Invoke-ChangeMeetingOrganizer. How it behaves, the gotchas hiding in the docs, a wrapper script that makes it safe to run, and the one rollout catch that makes the cmdlet lie to you.</summary>
    <content type="html"><![CDATA[<p>Every organisation has this meeting: the weekly stand-up that has run for three years, organised by someone who left last month. The series still fires, nobody can change it, and eventually someone recreates it from scratch, losing the history and forcing forty people to re-accept.</p>
<p>Exchange Online is finally fixing this. Message center item <a href="https://skybytes.io/nieuws#MC1227623"><strong>MC1227623</strong></a> announces a new PowerShell cmdlet, <code>Invoke-ChangeMeetingOrganizer</code>, rolling out worldwide between late June and July 2026 (GCC High and DoD follow through August). It transfers an existing meeting or series to a new organizer, who then gets full control: recurrence, attendees, description, everything. A user-facing version in Outlook and Teams is promised for later; the admin cmdlet arrives first, enabled by default, no configuration required.</p>
<p>This post covers how the transfer behaves, the gotchas that are easy to miss, a wrapper script that makes it safe to run, and the one thing that will stop you cold if you try it too early.</p>
<h2>How the transfer behaves</h2>
<p>The one-liner is simple enough:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token function">Invoke-ChangeMeetingOrganizer</span> <span class="token operator">-</span>Identity frank@contoso<span class="token punctuation">.</span>com <span class="token operator">-</span>EventId AAMkAGRlMGI0 <span class="token operator">-</span>NewOrganizer adele@contoso<span class="token punctuation">.</span>com</code></pre>
<p><code>Identity</code> is the mailbox of the <strong>current</strong> organizer, <code>EventId</code> identifies the meeting, <code>NewOrganizer</code> is the receiving mailbox. The cmdlet supports <code>-WhatIf</code> and <code>-Confirm</code>, which you should absolutely use given what it touches.</p>
<p>The interesting behaviour is in what happens around that call:</p>
<ul>
<li><strong>The transfer splits the series on a date.</strong> With <code>-TransferSeriesStartDate</code> you pick the effective date. Instances <em>before</em> that date stay untouched in the old organizer's calendar; everything <em>from</em> that date moves to the new organizer. Meeting history is preserved instead of orphaned. Note that this date cannot be in the past: the cmdlet rejects a start date earlier than today, so when in doubt, use tomorrow.</li>
<li><strong>Attendees inside your tenant notice almost nothing.</strong> Their existing calendar items are silently updated with the new organizer. No re-RSVP, and their personal tweaks to the item, such as reminder, category, show-as and private flag, survive.</li>
<li><strong>Attendees outside your tenant get the noisy version.</strong> They receive a cancellation truncating the old series, followed by a fresh invitation to the new one, plus extra messages for any series exceptions. They must re-accept. If your recurring meeting has external guests, plan the transfer and warn them.</li>
</ul>
<h2>The two gotchas in the design</h2>
<p><strong>The previous organizer is dropped from the meeting.</strong> After the transfer, the old organizer is no longer an attendee on the transferred portion. For offboarding that is exactly right. For a role change where the person still needs to attend, the new organizer has to re-invite them manually. Easy to miss, awkward to discover live.</p>
<p><strong>Identifying the right meeting has sharp edges.</strong> The cmdlet takes either <code>-EventId</code> or <code>-Subject</code>. <code>-Subject</code> is the convenient path: match exactly one meeting and it transfers; match several and the cmdlet refuses to guess, returning the list of candidates so you can rerun with the precise <code>-EventId</code>. That is genuinely helpful, but two things bite. Subjects are rarely unique across a mailbox's history, so the ambiguous case is often the norm, not the edge. And the docs are explicit that <code>-EventId</code> &quot;must identify the recurring series, not an individual instance&quot; — hand it an instance id and it fails. The subject match is also scoped tightly: it only looks at meetings on the <em>default calendar</em> where <code>-Identity</code> is the actual organizer, so a meeting that mailbox merely attends returns <code>No calendar events were found</code>, not a hit. So a safe transfer is less a one-liner and more: try by subject, read what comes back, and confirm before committing.</p>
<p>Which is exactly what the script below wraps.</p>
<h2>A wrapper that makes the transfer safe</h2>
<p>You could call <code>Invoke-ChangeMeetingOrganizer -Subject ...</code> directly. This wrapper adds the guardrails you want around something irreversible: it verifies both mailboxes before touching anything, lets you drive the transfer by subject or by an exact <code>EventId</code>, keeps <code>-WhatIf</code> working end to end, and fails loudly instead of printing a cheerful success over an error. It needs a single module, <code>ExchangeOnlineManagement</code> — the cmdlet finds the meeting itself, so there is no second module to install and no second source of truth to drift.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment">#Requires -Modules ExchangeOnlineManagement</span>

<span class="token comment">&lt;#
.SYNOPSIS
    Transfers meeting ownership in Exchange Online, safely.
.DESCRIPTION
    Wraps Invoke-ChangeMeetingOrganizer. Identify the meeting by -Subject (the
    cmdlet transfers it when exactly one matches, or returns the candidates so
    you can pick) or directly by -EventId. Validates both mailboxes first and
    supports -WhatIf end to end.
.EXAMPLE
    .\Transfer-MeetingOrganizer.ps1 -CurrentOrganizer frank@contoso.com `
        -NewOrganizer adele@contoso.com -Subject "Weekly stand-up" -WhatIf
.EXAMPLE
    .\Transfer-MeetingOrganizer.ps1 -CurrentOrganizer frank@contoso.com `
        -NewOrganizer adele@contoso.com -EventId AAMkAGRlMGI0
#></span>
<span class="token punctuation">[</span>CmdletBinding<span class="token punctuation">(</span>SupportsShouldProcess<span class="token punctuation">,</span> DefaultParameterSetName = <span class="token string">'BySubject'</span><span class="token punctuation">)</span><span class="token punctuation">]</span>
<span class="token keyword">param</span><span class="token punctuation">(</span>
    <span class="token namespace">[Parameter(Mandatory)]</span> <span class="token namespace">[string]</span> <span class="token variable">$CurrentOrganizer</span><span class="token punctuation">,</span>
    <span class="token namespace">[Parameter(Mandatory)]</span> <span class="token namespace">[string]</span> <span class="token variable">$NewOrganizer</span><span class="token punctuation">,</span>
    <span class="token punctuation">[</span>Parameter<span class="token punctuation">(</span>Mandatory<span class="token punctuation">,</span> ParameterSetName = <span class="token string">'BySubject'</span><span class="token punctuation">)</span><span class="token punctuation">]</span> <span class="token namespace">[string]</span> <span class="token variable">$Subject</span><span class="token punctuation">,</span>
    <span class="token punctuation">[</span>Parameter<span class="token punctuation">(</span>Mandatory<span class="token punctuation">,</span> ParameterSetName = <span class="token string">'ByEventId'</span><span class="token punctuation">)</span><span class="token punctuation">]</span> <span class="token namespace">[string]</span> <span class="token variable">$EventId</span><span class="token punctuation">,</span>
    <span class="token namespace">[datetime]</span> <span class="token variable">$TransferFrom</span> = <span class="token punctuation">(</span><span class="token function">Get-Date</span><span class="token punctuation">)</span><span class="token punctuation">.</span>Date<span class="token punctuation">.</span>AddDays<span class="token punctuation">(</span>1<span class="token punctuation">)</span>
<span class="token punctuation">)</span>

<span class="token variable">$ErrorActionPreference</span> = <span class="token string">"Stop"</span>

<span class="token comment"># 1. Both mailboxes must exist before we touch anything</span>
<span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$upn</span> in <span class="token variable">$CurrentOrganizer</span><span class="token punctuation">,</span> <span class="token variable">$NewOrganizer</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token variable">$null</span> = <span class="token function">Get-EXOMailbox</span> <span class="token operator">-</span>Identity <span class="token variable">$upn</span>
    <span class="token function">Write-Host</span> <span class="token string">"  [ok] mailbox found: <span class="token variable">$upn</span>"</span>
<span class="token punctuation">}</span>

<span class="token comment"># 2. Let the cmdlet identify the meeting: by EventId directly, or by Subject</span>
<span class="token comment">#    (it transfers a single match, or returns the candidates to choose from).</span>
<span class="token variable">$params</span> = @<span class="token punctuation">{</span>
    Identity                = <span class="token variable">$CurrentOrganizer</span>
    NewOrganizer            = <span class="token variable">$NewOrganizer</span>
    TransferSeriesStartDate = <span class="token variable">$TransferFrom</span>
    Confirm                 = <span class="token boolean">$false</span>
    ErrorAction             = <span class="token string">'Stop'</span>   <span class="token comment"># so "action is disabled" actually stops us</span>
<span class="token punctuation">}</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$PSCmdlet</span><span class="token punctuation">.</span>ParameterSetName <span class="token operator">-eq</span> <span class="token string">'ByEventId'</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token variable">$params</span><span class="token punctuation">.</span>EventId = <span class="token variable">$EventId</span>
    <span class="token variable">$label</span> = <span class="token string">"EventId <span class="token variable">$EventId</span>"</span>
<span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span>
    <span class="token variable">$params</span><span class="token punctuation">.</span>Subject = <span class="token variable">$Subject</span>
    <span class="token variable">$label</span> = <span class="token string">"subject '<span class="token variable">$Subject</span>'"</span>
<span class="token punctuation">}</span>

<span class="token comment"># 3. Transfer, honouring -WhatIf all the way down</span>
<span class="token variable">$doel</span> = <span class="token string">"<span class="token variable">$label</span> organised by <span class="token variable">$CurrentOrganizer</span> -> <span class="token variable">$NewOrganizer</span>, effective <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$TransferFrom</span><span class="token punctuation">.</span>ToShortDateString<span class="token punctuation">(</span><span class="token punctuation">)</span></span>)"</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$PSCmdlet</span><span class="token punctuation">.</span>ShouldProcess<span class="token punctuation">(</span><span class="token variable">$doel</span><span class="token punctuation">,</span> <span class="token string">"Transfer meeting organizer"</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>

    <span class="token variable">$result</span> = <span class="token function">Invoke-ChangeMeetingOrganizer</span> @params

    <span class="token comment"># A single-match transfer returns nothing. An ambiguous -Subject returns the</span>
    <span class="token comment"># candidate meetings instead of acting, so surface them and stop.</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$result</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token function">Write-Warning</span> <span class="token string">"Subject '<span class="token variable">$Subject</span>' matched more than one meeting. Nothing was transferred."</span>
        <span class="token function">Write-Host</span>   <span class="token string">"Pick the right EventId below and rerun with -EventId:`n"</span>
        <span class="token variable">$result</span> <span class="token punctuation">|</span> <span class="token function">Format-List</span> <span class="token operator">*</span>
        <span class="token keyword">return</span>
    <span class="token punctuation">}</span>

    <span class="token function">Write-Host</span> <span class="token string">"`nTransferred: <span class="token variable">$doel</span>"</span>
    <span class="token function">Write-Host</span> <span class="token string">"Remember:"</span>
    <span class="token function">Write-Host</span> <span class="token string">"  - <span class="token variable">$CurrentOrganizer</span> is NO LONGER an attendee; re-invite manually if needed."</span>
    <span class="token function">Write-Host</span> <span class="token string">"  - External attendees receive a cancellation plus a new invite and must re-accept."</span>
<span class="token punctuation">}</span></code></pre>
<p>A few deliberate choices in there. The mailbox check up front fails fast on typos, before anything irreversible. Identifying the meeting is left to the cmdlet itself — <code>-Subject</code> for convenience, <code>-EventId</code> when you already know it — which is what drops the second module. Parameter sets make <code>-Subject</code> and <code>-EventId</code> mutually exclusive, so you cannot accidentally pass both. When a subject is ambiguous the cmdlet returns the candidates rather than acting; the script surfaces that list and stops, so you rerun with the exact <code>-EventId</code> instead of transferring the wrong series. Finally, <code>-ErrorAction Stop</code> on the transfer means a failure actually stops the script, rather than printing a cheerful &quot;Transferred&quot; over the top of an error, which is exactly the trap the next section is about.</p>
<h2>The one that will stop you cold</h2>
<p>Here is the thing the announcement does not tell you clearly enough: <strong>the cmdlet can be present in your module and still refuse to run.</strong> Run it before the feature has landed in your tenant and you get:</p>
<pre class="language-text"><code class="language-text">Invoke-ChangeMeetingOrganizer: ||Transfer meeting action is disabled.</code></pre>
<p>That is not your session, your permissions, or your script. It is the MC1227623 rollout still in flight. The cmdlet ships with the Exchange Online module ahead of the server-side capability, so the command exists, accepts your parameters, resolves the meeting, and then the service declines at the last step.</p>
<p>Two things worth knowing here. First, there is no organisation-level switch to flip while you wait. Checking for one comes back empty:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token function">Get-OrganizationConfig</span> <span class="token punctuation">|</span> <span class="token function">Format-List</span> <span class="token operator">*</span>MeetingOrganizer*<span class="token punctuation">,</span> <span class="token operator">*</span>ChangeMeeting*
<span class="token comment"># (no matching properties)</span></code></pre>
<p>No property means no toggle: this is purely gated by the rollout, not by a setting you forgot. Second, this is precisely why the script uses <code>-ErrorAction Stop</code>. Without it, <code>Invoke-ChangeMeetingOrganizer</code> writes &quot;action is disabled&quot; as a non-terminating error and the script sails on to print &quot;Transferred&quot; underneath, telling you the transfer succeeded when it did nothing. Fail loudly, or you will trust a lie.</p>
<p>So if you are reading this during the rollout window and hit &quot;action is disabled&quot;: the plumbing is correct, the service simply is not ready. Try again in a few days with the exact same call.</p>
<p>One more trap from that same window, and this one wastes an afternoon if you let it: <strong>the disabled state does not always surface as the clean message above.</strong> The exact same call comes back sometimes as <code>Transfer meeting action is disabled</code>, and sometimes, with nothing changed, as a generic:</p>
<pre class="language-text"><code class="language-text">A server side error has occurred because of which the operation could not be completed. Please try again after some time. If the problem still persists, please reach out to MS support.</code></pre>
<p>That message all but tells you to open a support ticket. Don't, yet. It is intermittent: during the rollout the service is simply unreliable about how it reports a feature that is not switched on, and repeating the identical call often returns the real reason instead. Do not expect <code>-Verbose</code> to save you here, either. It shows the HTTP round-trip, which is reassuring, but the same call under <code>-Verbose</code> still returns the bare &quot;server side error&quot; as often as not, because the wording is the server's choice, not yours. During the rollout window, read a &quot;server side error&quot; on an otherwise valid call as one more spelling of &quot;not ready&quot;, not as a bug to escalate.</p>
<h2>Make it part of offboarding, not firefighting</h2>
<p>The real value of this cmdlet is not the one-off rescue, it is closing a structural gap. Offboarding processes are usually solid on licences, group memberships and mailbox conversion, and silent on the recurring meetings a person owns. Those only surface weeks later, when someone tries to move the stand-up and cannot.</p>
<p>So add one line to the offboarding checklist: <em>transfer or cancel the recurring meetings this person organises.</em> Transferring a series you can already name is the script above. Finding them all in the first place is a different job — the transfer cmdlet only acts on a meeting you can identify — so discover the mailbox's meetings first with <code>Get-CalendarDiagnosticObjects</code>, which stays inside the same Exchange module. Run it during offboarding, transfer what should live on, cancel the rest, and the three-year-old zombie series never gets the chance to exist.</p>
<p><em>Sources: <a href="https://skybytes.io/nieuws#MC1227623">MC1227623</a>, <a href="https://learn.microsoft.com/powershell/module/exchangepowershell/invoke-changemeetingorganizer?view=exchange-ps">Invoke-ChangeMeetingOrganizer on Microsoft Learn</a>, <a href="https://www.microsoft.com/microsoft-365/roadmap?searchterms=554937">Roadmap 554937</a>.</em></p>
]]></content>
  </entry>
  <entry>
    <title>Your shared mailboxes are login-capable accounts</title>
    <link href="https://skybytes.io/blog/shared-mailbox-login-capable/"/>
    <updated>2026-07-07T00:00:00Z</updated>
    <id>https://skybytes.io/blog/shared-mailbox-login-capable/</id>
    <summary>Convert a mailbox to shared and the Entra account often stays enabled. This script catches every shared mailbox that can still sign in — or is quietly burning a license.</summary>
    <content type="html"><![CDATA[<p>Shared mailboxes are the forgotten citizens of your tenant. Everybody uses them, nobody pays attention to them, and that is exactly the problem: <strong>every shared mailbox has a full-blown user account in Entra ID</strong>. And that account can simply be enabled.</p>
<h2>Why this is a real risk</h2>
<p>A shared mailbox is supposed to be a mailbox without an owner: people access it through their own account with delegated permissions. The underlying Entra account is a technical by-product and should be blocked for sign-in. In practice, this goes wrong in two ways:</p>
<ul>
<li><strong>Converted mailboxes.</strong> An employee leaves, the mailbox is converted to shared &quot;so we can still get to it&quot;. The account stays enabled, the password stays valid, and sometimes even the license lingers. You now have a sign-in-capable account that nobody is watching anymore, often without MFA registration from an active owner.</li>
<li><strong>Manually created mailboxes</strong> where the &quot;block sign-in&quot; step was skipped. The password is system-generated and unknown, sure, but unknown is not the same as unusable: whoever has the rights to reset passwords has an inconspicuous way in.</li>
</ul>
<p>For those who think in framework terms: this directly touches the management of inactive accounts and least privilege. An auditor who asks about this does not want to see a policy, but a list. We are going to make that list now.</p>
<h2>The measurement</h2>
<p>The nasty part is that you cannot see this in a single console. Exchange knows the mailbox, Entra knows the account and the licenses. So: pull the shared mailboxes from Exchange Online, per mailbox do the crosswalk to Entra via the <code>ExternalDirectoryObjectId</code>, and check <code>AccountEnabled</code> and the licenses there.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment"># Required: ExchangeOnlineManagement and Microsoft.Graph.Users</span>
<span class="token function">Connect-ExchangeOnline</span>
<span class="token function">Connect-MgGraph</span> <span class="token operator">-</span>Scopes <span class="token string">"User.Read.All"</span><span class="token punctuation">,</span><span class="token string">"Directory.Read.All"</span>

<span class="token comment"># 1) All shared mailboxes from Exchange</span>
<span class="token variable">$shared</span> = <span class="token function">Get-EXOMailbox</span> <span class="token operator">-</span>RecipientTypeDetails SharedMailbox <span class="token operator">-</span>ResultSize Unlimited <span class="token punctuation">|</span>
    <span class="token function">Select-Object</span> DisplayName<span class="token punctuation">,</span> PrimarySmtpAddress<span class="token punctuation">,</span> ExternalDirectoryObjectId

<span class="token comment"># 2) Crosswalk to Entra: is the account enabled, and are there licenses attached?</span>
<span class="token variable">$report</span> = <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$m</span> in <span class="token variable">$shared</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">try</span> <span class="token punctuation">{</span>
        <span class="token variable">$u</span> = <span class="token function">Get-MgUser</span> <span class="token operator">-</span>UserId <span class="token variable">$m</span><span class="token punctuation">.</span>ExternalDirectoryObjectId `
             <span class="token operator">-</span>Property Id<span class="token punctuation">,</span> DisplayName<span class="token punctuation">,</span> AccountEnabled<span class="token punctuation">,</span> UserType<span class="token punctuation">,</span> AssignedLicenses

        <span class="token namespace">[pscustomobject]</span>@<span class="token punctuation">{</span>
            Mailbox        = <span class="token variable">$m</span><span class="token punctuation">.</span>DisplayName
            Address        = <span class="token variable">$m</span><span class="token punctuation">.</span>PrimarySmtpAddress
            AccountEnabled = <span class="token variable">$u</span><span class="token punctuation">.</span>AccountEnabled
            Licenses       = @<span class="token punctuation">(</span><span class="token variable">$u</span><span class="token punctuation">.</span>AssignedLicenses<span class="token punctuation">)</span><span class="token punctuation">.</span>Count
            Verdict        = <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$u</span><span class="token punctuation">.</span>AccountEnabled<span class="token punctuation">)</span>              <span class="token punctuation">{</span> <span class="token string">"SIGN-IN ENABLED"</span> <span class="token punctuation">}</span>
                             <span class="token keyword">elseif</span> <span class="token punctuation">(</span>@<span class="token punctuation">(</span><span class="token variable">$u</span><span class="token punctuation">.</span>AssignedLicenses<span class="token punctuation">)</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span> <span class="token punctuation">{</span> <span class="token string">"LICENSED"</span> <span class="token punctuation">}</span>
                             <span class="token keyword">else</span>                                  <span class="token punctuation">{</span> <span class="token string">"OK"</span> <span class="token punctuation">}</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>
    <span class="token keyword">catch</span> <span class="token punctuation">{</span>
        <span class="token namespace">[pscustomobject]</span>@<span class="token punctuation">{</span>
            Mailbox = <span class="token variable">$m</span><span class="token punctuation">.</span>DisplayName<span class="token punctuation">;</span> Address = <span class="token variable">$m</span><span class="token punctuation">.</span>PrimarySmtpAddress
            AccountEnabled = <span class="token variable">$null</span><span class="token punctuation">;</span> Licenses = <span class="token variable">$null</span>
            Verdict = <span class="token string">"Not found in Graph"</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token comment"># 3) Worst cases on top, and record the results</span>
<span class="token variable">$report</span> <span class="token punctuation">|</span> <span class="token function">Sort-Object</span> Verdict <span class="token punctuation">|</span> <span class="token function">Format-Table</span> <span class="token operator">-</span>AutoSize
<span class="token variable">$report</span> <span class="token punctuation">|</span> <span class="token function">Export-Csv</span> <span class="token punctuation">.</span>\shared-mailbox-measurement<span class="token punctuation">.</span>csv <span class="token operator">-</span>NoTypeInformation <span class="token operator">-</span>Encoding UTF8</code></pre>
<h2>Reading the result</h2>
<p>The <code>Verdict</code> column tells you where the work is:</p>
<table>
<thead>
<tr>
<th>Verdict</th>
<th>Meaning</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>SIGN-IN ENABLED</td>
<td>The account is enabled and accepts authentication</td>
<td>Block it, today</td>
</tr>
<tr>
<td>LICENSED</td>
<td>Account disabled, but a license is attached</td>
<td>Reclaim the license (shared mailboxes up to 50 GB without an archive do not need one)</td>
</tr>
<tr>
<td>OK</td>
<td>Disabled and license-free</td>
<td>Nothing, this is how it should be</td>
</tr>
</tbody>
</table>
<p>Blocking is a single line:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token function">Update-MgUser</span> <span class="token operator">-</span>UserId &lt;objectId> <span class="token operator">-</span>AccountEnabled:<span class="token boolean">$false</span></code></pre>
<p>And to those who think &quot;we catch this with Conditional Access&quot;: maybe. But a blocked account is a guarantee, a CA policy is a configuration that can change. For accounts that should never sign in, the block is the only correct layer, with CA as the safety net on top of it, not the other way around.</p>
<h2>From measurement to assurance</h2>
<p>Running this script once gives you a cleanup action. The real gain is in repetition: every mailbox converted after today reintroduces the risk. So schedule the measurement periodically, or better: make blocking the account a fixed step in your offboarding and conversion process, and use the measurement as a check on that. Policy says what should happen; the export shows what has happened.</p>
<p>Questions about this script, or want to know how something like this fits into a broader baseline assessment? <a href="/#contact">Let me know</a>.</p>
]]></content>
  </entry>
  <entry>
    <title>Trust, but Verify Your Microsoft 365 Tenant</title>
    <link href="https://skybytes.io/blog/blog-launch/"/>
    <updated>2026-07-06T00:00:00Z</updated>
    <id>https://skybytes.io/blog/blog-launch/</id>
    <summary>From assumptions to evidence: why this blog exists, what you&#39;ll find here, and why every post ends with something you can verify yourself.</summary>
    <content type="html"><![CDATA[<p>This is the first post on this blog, so a short explanation of what you can expect here seems appropriate.</p>
<p>I work daily in Microsoft 365 environments where compliance is not a paper exercise but a requirement: BIO2, NIS2, and the Dutch Cybersecurity Act. What stands out most in that work is the gap between <em>thinking everything is configured correctly</em> and <em>being able to prove that it is</em>. Almost every organization has policies. Far fewer organizations can demonstrate, on any random Tuesday afternoon, that their tenant actually complies with those policies.</p>
<p>That gap is what this blog is about.</p>
<h2>What you'll find here</h2>
<p>The topics will be familiar if you've already looked around this site:</p>
<ul>
<li><strong>Microsoft 365 governance</strong>: ownership, lifecycle management, external sharing, and why a decision log is often more valuable than a thick policy document.</li>
<li><strong>Compliance frameworks in practice</strong>: what BIO2 and NIS2 actually mean for a Microsoft 365 tenant, translated into configuration instead of intention.</li>
<li><strong>Automation with PowerShell</strong>: measuring configuration instead of assuming it, using tools such as Microsoft365DSC, Microsoft Graph, and the familiar PowerShell modules.</li>
<li><strong>Azure</strong>: where it intersects with the modern workplace, from Conditional Access to logging.</li>
</ul>
<p>You won't find rewrites of product announcements or &quot;10 tips&quot; listicles. Instead, you'll find real-world scenarios I've encountered, worked out into something you can reproduce yourself.</p>
<h2>Publishing cadence</h2>
<p>I'm aiming for a weekly schedule, with the usual caveat that client work comes first. If you don't want to miss a post, there's an <a href="/blog/feed.xml">RSS feed</a>, and I announce the larger articles on <a href="https://www.linkedin.com/in/kevin-oosterlaken/">LinkedIn</a>.</p>
<p>Have a question or a topic you'd like to see covered? <a href="/#contact">Get in touch</a>.</p>
]]></content>
  </entry>
  <entry>
    <title>Auto-generating a Maester config from your custom Pester tests</title>
    <link href="https://skybytes.io/blog/auto-generate-maester-config/"/>
    <updated>2026-07-05T00:00:00Z</updated>
    <id>https://skybytes.io/blog/auto-generate-maester-config/</id>
    <summary>Generate your maester-config.json automatically from your custom Pester tests, so severities and titles never drift out of sync again.</summary>
    <content type="html"><![CDATA[<p>If you use <a href="https://maester.dev">Maester</a> to continuously test the security configuration of a Microsoft 365 tenant, you have probably discovered that it happily runs your <strong>own</strong> Pester tests next to the built-in ones. That is where it gets powerful: you write a test once, and Maester runs it, scores it, and drops it into a nice report.</p>
<p>There is one small piece of friction, though. Maester lets you override the <strong>severity</strong> and the <strong>title</strong> of every test through a <code>maester-config.json</code> file. Maintaining that JSON by hand is tedious, error-prone, and worst of all it silently drifts out of sync with your actual tests the moment someone adds a new one.</p>
<p>This post shows a tiny PowerShell script that removes that friction entirely: it reads your test files, figures out which tests exist, looks up the severity for each of them, and writes a fresh <code>maester-config.json</code> for you. Run it as part of your build and the config can never fall behind again.</p>
<h2>The problem in one picture</h2>
<p>Maester's per-test overrides live in a file that looks roughly like this:</p>
<pre class="language-json"><code class="language-json"><span class="token punctuation">{</span>
  <span class="token property">"TestSettings"</span><span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token punctuation">{</span>
      <span class="token property">"Id"</span><span class="token operator">:</span> <span class="token string">"MFA-001"</span><span class="token punctuation">,</span>
      <span class="token property">"Severity"</span><span class="token operator">:</span> <span class="token string">"Critical"</span><span class="token punctuation">,</span>
      <span class="token property">"Title"</span><span class="token operator">:</span> <span class="token string">"MFA-001 - Require MFA for privileged roles"</span>
    <span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>
      <span class="token property">"Id"</span><span class="token operator">:</span> <span class="token string">"SPO-004"</span><span class="token punctuation">,</span>
      <span class="token property">"Severity"</span><span class="token operator">:</span> <span class="token string">"Medium"</span><span class="token punctuation">,</span>
      <span class="token property">"Title"</span><span class="token operator">:</span> <span class="token string">"SPO-004 - Restrict external sharing"</span>
    <span class="token punctuation">}</span>
  <span class="token punctuation">]</span>
<span class="token punctuation">}</span></code></pre>
<p>Every object binds a <strong>test Id</strong> to a <strong>Severity</strong> (<code>Critical</code>, <code>High</code>, <code>Medium</code>, <code>Low</code>, <code>Info</code>) and a display <strong>Title</strong>. Maester uses those values when it renders the report and when it decides how loud a failure should be.</p>
<p>Now imagine a repository with dozens of custom tests spread across many files. Keeping this list correct by hand means:</p>
<ul>
<li>Remembering to add an entry every time you write a test.</li>
<li>Removing entries for tests you deleted.</li>
<li>Keeping the title in the JSON identical to the title in the test.</li>
<li>Never fat-fingering a severity.</li>
</ul>
<p>That is exactly the kind of bookkeeping a computer should do.</p>
<hr>
<h2>The idea</h2>
<p>The trick is to treat your test files as the <strong>single source of truth</strong> and generate the config from them. That only works if your tests follow a small, predictable naming convention. A convention like this is easy to adopt and pays off immediately:</p>
<pre class="language-powershell"><code class="language-powershell">Describe <span class="token string">'Evaluating multi-factor authentication'</span> <span class="token punctuation">{</span>

    It <span class="token string">'MFA-001: Require MFA for privileged roles'</span> <span class="token operator">-</span>Tag <span class="token string">"Entra ID"</span><span class="token punctuation">,</span> <span class="token string">"Access control"</span> <span class="token punctuation">{</span>
        <span class="token comment"># ... your assertions ...</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>The important part is the <code>It</code> line. Each test starts with a short, structured <strong>code</strong> (<code>MFA-001</code>), a colon, and a human-readable description. That single line gives us everything we need:</p>
<ul>
<li>The part before the colon (<code>MFA-001</code>) becomes the <strong>Id</strong>.</li>
<li>The part after the colon becomes the <strong>description</strong>.</li>
</ul>
<p>The script then builds the title as <code>Id - description</code>, so <code>MFA-001: Require MFA for privileged roles</code> becomes the title <code>MFA-001 - Require MFA for privileged roles</code>. Repeating the code in the title is deliberate: it means the Id is visible at a glance in the Maester report, not just in the raw config. If you would rather show only the description, drop the <code>&quot;$id - &quot;</code> prefix in the script — it is a one-line change.</p>
<p>The only thing still missing is the severity, and that lives in a separate lookup so that non-engineers can own it. More on that below.</p>
<hr>
<h2>A place to keep severities</h2>
<p>Rather than hard-coding severities in the script, keep them in a simple mapping file. A two-column CSV is more than enough and can be edited by anyone, including people who do not touch PowerShell:</p>
<pre class="language-csv"><code class="language-csv"><span class="token value">Id;Severity</span>
<span class="token value">MFA-001;Critical</span>
<span class="token value">SPO-004;Medium</span>
<span class="token value">EXO-002;High</span>
<span class="token value">LOG-001;Info</span></code></pre>
<blockquote>
<p><strong>Tip:</strong> In practice this mapping file is a great place to also record <em>why</em> a control has a given severity, or to link it to whatever framework you report against (CIS, ISO 27001, NIS2, your own internal baseline). The script below only cares about the <code>Id</code> and <code>Severity</code> columns, so you can add as many extra columns as you like without changing a single line of code.</p>
</blockquote>
<hr>
<h2>The script</h2>
<p>Here is the whole thing. It is deliberately small, a little over fifty lines of PowerShell with no external modules.</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token comment"># Stop on any error</span>
<span class="token variable">$ErrorActionPreference</span> = <span class="token string">"Stop"</span>

<span class="token comment"># Folder layout assumed here:</span>
<span class="token comment">#   .\generate-maester-config.ps1   &lt;- this script</span>
<span class="token comment">#   .\severity-mapping.csv          &lt;- Id;Severity lookup</span>
<span class="token comment">#   ..\tests\Custom\*.Tests.ps1     &lt;- your custom Pester tests</span>

<span class="token variable">$scriptRoot</span> = <span class="token variable">$PSScriptRoot</span>
<span class="token variable">$repoRoot</span>   = <span class="token function">Split-Path</span> <span class="token operator">-</span>Parent <span class="token variable">$scriptRoot</span>

<span class="token comment"># 1. Load the severity lookup into a hashtable for fast access</span>
<span class="token variable">$csvPath</span> = <span class="token function">Join-Path</span> <span class="token operator">-</span>Path <span class="token variable">$scriptRoot</span> <span class="token operator">-</span>ChildPath <span class="token string">"severity-mapping.csv"</span>

<span class="token variable">$severityLookup</span> = @<span class="token punctuation">{</span><span class="token punctuation">}</span>
<span class="token function">Import-Csv</span> <span class="token operator">-</span>Path <span class="token variable">$csvPath</span> <span class="token operator">-</span>Delimiter <span class="token string">";"</span> <span class="token punctuation">|</span> <span class="token function">ForEach-Object</span> <span class="token punctuation">{</span>
    <span class="token variable">$severityLookup</span><span class="token punctuation">[</span><span class="token variable">$_</span><span class="token punctuation">.</span>Id<span class="token punctuation">]</span> = <span class="token variable">$_</span><span class="token punctuation">.</span>Severity
<span class="token punctuation">}</span>

<span class="token comment"># 2. Prepare the result set and the regex that recognises a test</span>
<span class="token variable">$testSettings</span> = <span class="token namespace">[System.Collections.Generic.List[object]]</span>::new<span class="token punctuation">(</span><span class="token punctuation">)</span>

<span class="token comment"># Matches:  It 'CODE-123: Some description' ...</span>
<span class="token variable">$pattern</span> = <span class="token string">"^\s*It\s+'([A-Z]+-\d+):\s*(.*?)'"</span>

<span class="token comment"># 3. Find every custom test file</span>
<span class="token variable">$testsPath</span> = <span class="token function">Join-Path</span> <span class="token operator">-</span>Path <span class="token variable">$repoRoot</span> <span class="token operator">-</span>ChildPath <span class="token string">"tests\Custom"</span>
<span class="token variable">$files</span> = <span class="token function">Get-ChildItem</span> <span class="token operator">-</span>Path <span class="token variable">$testsPath</span> <span class="token operator">-</span>Recurse <span class="token operator">-</span><span class="token keyword">Filter</span> <span class="token operator">*</span><span class="token punctuation">.</span>Tests<span class="token punctuation">.</span>ps1

<span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$file</span> in <span class="token variable">$files</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$line</span> in <span class="token punctuation">(</span><span class="token function">Get-Content</span> <span class="token variable">$file</span><span class="token punctuation">.</span>FullName<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$line</span> <span class="token operator">-match</span> <span class="token variable">$pattern</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token variable">$id</span>    = <span class="token variable">$matches</span><span class="token punctuation">[</span>1<span class="token punctuation">]</span>
            <span class="token variable">$desc</span>  = <span class="token variable">$matches</span><span class="token punctuation">[</span>2<span class="token punctuation">]</span>
            <span class="token variable">$title</span> = <span class="token string">"<span class="token variable">$id</span> - <span class="token variable">$desc</span>"</span>

            <span class="token comment"># Look up the severity, fall back gracefully if it's missing</span>
            <span class="token variable">$severity</span> = <span class="token variable">$severityLookup</span><span class="token punctuation">[</span><span class="token variable">$id</span><span class="token punctuation">]</span>
            <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">-not</span> <span class="token variable">$severity</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
                <span class="token variable">$severity</span> = <span class="token string">"Unknown"</span>
                <span class="token function">Write-Warning</span> <span class="token string">"No severity found for <span class="token variable">$id</span>"</span>
            <span class="token punctuation">}</span>

            <span class="token variable">$testSettings</span><span class="token punctuation">.</span>Add<span class="token punctuation">(</span><span class="token namespace">[PSCustomObject]</span>@<span class="token punctuation">{</span>
                Id       = <span class="token variable">$id</span>
                Severity = <span class="token variable">$severity</span>
                Title    = <span class="token variable">$title</span>
            <span class="token punctuation">}</span><span class="token punctuation">)</span>

            <span class="token function">Write-Host</span> <span class="token string">"  [+] <span class="token variable">$title</span> (<span class="token variable">$severity</span>)"</span>
        <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token variable">$testSettings</span><span class="token punctuation">.</span>Count <span class="token operator">-eq</span> 0<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token function">Write-Warning</span> <span class="token string">"No tests found. Does your It-pattern look like  It 'CODE-123: Description' ?"</span>
    <span class="token keyword">return</span>
<span class="token punctuation">}</span>

<span class="token comment"># 4. Build the JSON and write it next to your tests</span>
<span class="token variable">$json</span> = @<span class="token punctuation">{</span> TestSettings = <span class="token variable">$testSettings</span> <span class="token punctuation">}</span> <span class="token punctuation">|</span> <span class="token function">ConvertTo-Json</span> <span class="token operator">-</span>Depth 3
<span class="token variable">$outputPath</span> = <span class="token function">Join-Path</span> <span class="token operator">-</span>Path <span class="token variable">$testsPath</span> <span class="token operator">-</span>ChildPath <span class="token string">"maester-config.json"</span>
<span class="token variable">$json</span> <span class="token punctuation">|</span> <span class="token function">Out-File</span> <span class="token operator">-</span>FilePath <span class="token variable">$outputPath</span> <span class="token operator">-</span>Encoding UTF8

<span class="token function">Write-Host</span> <span class="token string">"`nDone. <span class="token function">$<span class="token punctuation">(</span><span class="token variable">$testSettings</span><span class="token punctuation">.</span>Count<span class="token punctuation">)</span></span> test(s) written to maester-config.json"</span></code></pre>
<hr>
<h2>How it works, step by step</h2>
<p><strong>1. Load the severities into a hashtable.</strong>
Reading the CSV once into a <code>@{}</code> hashtable means every lookup afterwards is an instant key access instead of a repeated scan of the file. For a handful of tests it hardly matters; for hundreds it keeps things snappy.</p>
<p><strong>2. Define the recognition pattern.</strong>
The regex <code>^\s*It\s+'([A-Z]+-\d+):\s*(.*?)'</code> is the heart of the script. It says: <em>a line that starts with <code>It</code>, followed by a quoted string that begins with an uppercase code, a dash, some digits, a colon, and then the description.</em> The two capture groups hand us the <strong>Id</strong> and the <strong>description</strong> directly. If your codes use a different shape (say numbers only, or a longer prefix), this one line is all you need to adjust.</p>
<p><strong>3. Walk every test file.</strong>
<code>Get-ChildItem -Recurse -Filter *.Tests.ps1</code> collects your Pester test files (and only those, thanks to the <code>.Tests.ps1</code> filter), and the script reads them line by line. Every line that matches the pattern becomes one entry in the result set. Because it works purely on text, it does not need to <em>run</em> your tests, so generating the config is fast and has no side effects on your tenant.</p>
<p><strong>4. Fail loudly, then continue.</strong>
If a test has no matching severity in the CSV, the script does not crash. It assigns <code>Unknown</code> and emits a <code>Write-Warning</code>. That way a forgotten mapping shows up as a visible nudge in your build log rather than a broken pipeline. You get the config <em>and</em> the reminder to fix the mapping.</p>
<p><strong>5. Emit the JSON.</strong>
Finally, <code>ConvertTo-Json</code> turns the collected objects into exactly the structure Maester expects, and it lands right next to your tests as <code>maester-config.json</code>.</p>
<blockquote>
<p><strong>A note on the collection.</strong> The results go into a <code>System.Collections.Generic.List[object]</code> rather than a plain <code>$array += ...</code>. It is a small thing, but <code>+=</code> rebuilds the entire array on every iteration, which is exactly the kind of quiet inefficiency you do not want in a script whose whole point is letting the machine do the tedious work. The list just grows.</p>
</blockquote>
<hr>
<h2>Wiring it into your workflow</h2>
<p>Because the script is idempotent and side-effect free, it fits anywhere:</p>
<ul>
<li><strong>Locally</strong>, run it after adding a test so your config is always current before you commit.</li>
<li><strong>In CI</strong>, run it as a build step and either commit the result or fail the build if the generated file differs from the checked-in one. That turns &quot;the config drifted&quot; into a red pipeline instead of a silent bug.</li>
<li><strong>As a pre-commit hook</strong>, so nobody can forget.</li>
</ul>
<p>A useful CI check is to regenerate and compare:</p>
<pre class="language-powershell"><code class="language-powershell"><span class="token punctuation">.</span>\generate-maester-config<span class="token punctuation">.</span>ps1
git <span class="token function">diff</span> <span class="token operator">--</span><span class="token function">exit-code</span> tests\Custom\maester-config<span class="token punctuation">.</span>json</code></pre>
<p>If that <code>git diff</code> returns a non-zero exit code, someone added or changed a test without regenerating the config, and CI will tell them.</p>
<hr>
<h2>Why bother?</h2>
<p>A generated config buys you three things that a hand-written one never will:</p>
<ol>
<li><strong>It cannot drift.</strong> The config is derived from the tests, so it is correct by construction.</li>
<li><strong>Severities become a shared, reviewable artefact.</strong> A CSV that non-engineers can edit means your security or compliance people can tune severities without opening a <code>.ps1</code> file, and every change is visible in a pull request.</li>
<li><strong>Onboarding is trivial.</strong> New tests just need to follow the naming convention. The plumbing takes care of itself.</li>
</ol>
<p>The whole thing is a little over fifty lines of vanilla PowerShell, has no dependencies beyond what ships with the box, and works with any Maester setup that runs custom Pester tests. If you have been maintaining <code>maester-config.json</code> by hand, this is a small afternoon's work that you will be glad you did.</p>
<hr>
<p><em>Have a different naming convention or a richer severity model? The regex and the lookup are the only two things you need to touch. Everything else stays the same.</em></p>
]]></content>
  </entry>
</feed>
