<?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-08T00:00:00Z</updated>
  <id>https://skybytes.io/blog/</id>
  <author><name>Kevin Oosterlaken</name></author>
  <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>
