<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator>
  <link href="https://rksolutions.nl/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://rksolutions.nl/" rel="alternate" type="text/html" />
  <updated>2026-10-13T06:13:40+00:00</updated>
  <id>https://rksolutions.nl/feed.xml</id>
  <icon>https://rksolutions.nl/favicon-2026.png</icon>
  <logo>https://rksolutions.nl/favicon-2026.png</logo>

  
    <title type="html">RK Solutions</title>
  

  
    <subtitle>Modern Workplace &amp; Automation — Roy Klooster</subtitle>
  

  
    <author>
        <name>Roy Klooster</name>
    </author>
  

  
  
  
  
    <entry>
      

      <title type="html">Thought Global Reader Could Read Everything? Intune Says 401</title>
      <link href="https://rksolutions.nl/posts/thought-global-reader-could-read-everything-intune-says-401/" rel="alternate" type="text/html" title="Thought Global Reader Could Read Everything? Intune Says 401" />
      <published>2026-09-22T00:00:00+00:00</published>
      <updated>2026-09-22T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/thought-global-reader-could-read-everything-intune-says-401</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/thought-global-reader-could-read-everything-intune-says-401/"><![CDATA[<p>I help out on this tenant now and then. Someone wants a second pair of eyes on a configuration, I look at it and say whether it can be tightened up or not, and for that I hold <strong>Global Reader</strong> and nothing else. This time I wanted a quick read on the overall state of the environment, and the fastest way I know to get that is to run <code class="language-plaintext highlighter-rouge">Get-IntuneAnomaliesReport</code> from my own <a href="/posts/rksolutions-powershell-module/">RKSolutions module</a> and see what comes back: stale devices, failed deployments, BitLocker escrow gaps, the usual.</p>

<p>The report returned zero devices and a <code class="language-plaintext highlighter-rouge">401 Unauthorized</code> on every <code class="language-plaintext highlighter-rouge">deviceManagement</code> endpoint it touched. That struck me as odd, so I went digging into what the problem actually was.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#what-i-found">What I found</a>
    <ul>
      <li><a href="#the-error-that-sends-you-the-wrong-way">The error that sends you the wrong way</a></li>
      <li><a href="#running-the-checks">Running the checks</a></li>
      <li><a href="#the-chicken-and-egg-problem">The chicken-and-egg problem</a></li>
    </ul>
  </li>
  <li><a href="#where-microsofts-own-docs-disagree">Where Microsoft’s own docs disagree</a>
    <ul>
      <li><a href="#what-the-admin-center-says-instead">What the admin center says instead</a></li>
    </ul>
  </li>
  <li><a href="#the-fix">The fix</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
  <li><a href="#related">Related</a></li>
  <li><a href="#references">References</a></li>
</ul>

<h2 id="what-i-found">What I found</h2>

<p>The <code class="language-plaintext highlighter-rouge">401</code> was never about authentication, and nothing in the status line tells you that. Here is how the error actually reads, what the checks eliminate, and where they run out of road.</p>

<h3 id="the-error-that-sends-you-the-wrong-way">The error that sends you the wrong way</h3>

<p>This is what the report printed, with the tenant-specific parts removed:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Starting device data collection...
Fetching Autopilot devices...
Processing 0 devices...
Processed 0 devices out of 0 total devices
Error: GET https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/
HTTP/1.1 401 Unauthorized
</code></pre></div></div>

<p>The useful part is not in that status line. It is in the response body, double-escaped, several layers deep. The real thing arrives as a single line; the indentation is mine:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"code"</span><span class="p">:</span><span class="w"> </span><span class="s2">"UnknownError"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{
      </span><span class="se">\"</span><span class="s2">ErrorCode</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">Forbidden</span><span class="se">\"</span><span class="s2">,
      </span><span class="se">\"</span><span class="s2">Message</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">{
        </span><span class="se">\"</span><span class="s2">_version</span><span class="se">\"</span><span class="s2">: 3,
        </span><span class="se">\"</span><span class="s2">Message</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">An error has occurred - Operation ID (for customer support): 00000000-0000-0000-0000-000000000000 - Url: https://proxy.msubXX.manage.microsoft.com/DeviceEnrollmentFE/StatelessDeviceEnrollmentFEService/deviceManagement/windowsAutopilotDeploymentProfiles?api-version=5025-11-25</span><span class="se">\"</span><span class="s2">,
        </span><span class="se">\"</span><span class="s2">HttpHeaders</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="s2">{</span><span class="se">\"</span><span class="s2">WWW-Authenticate</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">Bearer</span><span class="se">\"</span><span class="s2">}</span><span class="se">\"</span><span class="s2">
      }</span><span class="se">\"</span><span class="s2">
    }"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two signals in there change the diagnosis:</p>

<table>
  <thead>
    <tr>
      <th>Signal</th>
      <th>What it means</th>
      <th>How I know</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The body says <code class="language-plaintext highlighter-rouge">"ErrorCode":"Forbidden"</code> while the HTTP status line says <code class="language-plaintext highlighter-rouge">401</code></td>
      <td>These are two different failures. <code class="language-plaintext highlighter-rouge">401</code> means “I do not know who you are”. <code class="language-plaintext highlighter-rouge">Forbidden</code> means “I know who you are, and no”. The inner string is Intune’s own error code, and it is the one telling the truth. Note there is no literal <code class="language-plaintext highlighter-rouge">403</code> anywhere in the payload - that mismatch is exactly what makes this confusing.</td>
      <td>The two statuses are defined in <a href="https://www.rfc-editor.org/rfc/rfc9110#name-401-unauthorized">RFC 9110</a>, §15.5.2 for <code class="language-plaintext highlighter-rouge">401</code> and §15.5.4 for <code class="language-plaintext highlighter-rouge">403</code>. That Graph surfaces an Intune authorization refusal under a <code class="language-plaintext highlighter-rouge">401</code> status is my observation, not documented behavior.</td>
    </tr>
    <tr>
      <td>The host is <code class="language-plaintext highlighter-rouge">*.manage.microsoft.com</code></td>
      <td>The request left Graph and reached the Intune service itself. Your token was accepted; Intune is the component that said no.</td>
      <td>Microsoft documents <code class="language-plaintext highlighter-rouge">manage.microsoft.com</code> and <code class="language-plaintext highlighter-rouge">*.manage.microsoft.com</code> as the <strong>Intune client and host service</strong> endpoint, set ID 163, in <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/intune-endpoints">Network endpoints for Microsoft Intune</a>.</td>
    </tr>
  </tbody>
</table>

<p>The nesting is hostile enough - a JSON string inside a JSON string inside a JSON string - that it is worth having something dig it out:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">try</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/beta/deviceManagement'</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">Stop</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$outer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ErrorDetails</span><span class="o">.</span><span class="nf">Message</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
    </span><span class="nv">$inner</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$outer</span><span class="o">.</span><span class="nf">error</span><span class="o">.</span><span class="nf">message</span><span class="w">     </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
    </span><span class="nv">$body</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$inner</span><span class="o">.</span><span class="nf">Message</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">Message</span><span class="w">
    </span><span class="nx">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$body</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s1">'Url:\s*(https?://[^/?\s]+)'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nv">$refusedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$Matches</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">[</span><span class="n">pscustomobject</span><span class="p">]@{</span><span class="w">
        </span><span class="nx">OuterCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$outer</span><span class="err">.</span><span class="nx">error</span><span class="err">.</span><span class="nx">code</span><span class="w">
        </span><span class="nx">InnerCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$inner</span><span class="err">.</span><span class="nx">ErrorCode</span><span class="w">
        </span><span class="nx">RefusedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$refusedBy</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OuterCode    InnerCode  RefusedBy
---------    ---------  ---------
UnknownError Forbidden  https://proxy.msubXX.manage.microsoft.com
</code></pre></div></div>

<p>Honest, at least. It tells you <em>who</em> said no. It does not tell you <em>why</em>, and that is as far as Graph is going to take you.</p>

<h3 id="running-the-checks">Running the checks</h3>

<p>So the next question is whether this is the token, the tenant, or the identity. Here is that as one read-only script - every call is a <code class="language-plaintext highlighter-rouge">GET</code> - which also installs the Graph authentication module if you do not have it.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#Requires -Version 5.1</span><span class="w">
</span><span class="c"># Intune access diagnostic. Read-only: every call is a GET.</span><span class="w">

</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Module</span><span class="w"> </span><span class="nt">-ListAvailable</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="nx">Microsoft.Graph.Authentication</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Write-Host</span><span class="w"> </span><span class="s1">'Installing Microsoft.Graph.Authentication for the current user...'</span><span class="w"> </span><span class="nt">-ForegroundColor</span><span class="w"> </span><span class="nx">Yellow</span><span class="w">
    </span><span class="n">Install-Module</span><span class="w"> </span><span class="nx">Microsoft.Graph.Authentication</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="nx">CurrentUser</span><span class="w"> </span><span class="nt">-Force</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="n">Import-Module</span><span class="w"> </span><span class="nx">Microsoft.Graph.Authentication</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">Stop</span><span class="w">

</span><span class="c"># Fresh token: -Scopes is ignored on an existing session.</span><span class="w">
</span><span class="n">Disconnect-MgGraph</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
</span><span class="nx">Connect-MgGraph</span><span class="w"> </span><span class="nt">-NoWelcome</span><span class="w"> </span><span class="nt">-Scopes</span><span class="w"> </span><span class="p">@(</span><span class="w">
    </span><span class="s1">'DeviceManagementServiceConfig.Read.All'</span><span class="w">
    </span><span class="s1">'Directory.Read.All'</span><span class="w">
    </span><span class="s1">'Organization.Read.All'</span><span class="w">
    </span><span class="s1">'User.Read'</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="kr">function</span><span class="w"> </span><span class="nf">Get-Graph</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">([</span><span class="n">Parameter</span><span class="p">(</span><span class="n">Mandatory</span><span class="p">)][</span><span class="n">string</span><span class="p">]</span><span class="nv">$Uri</span><span class="p">)</span><span class="w">
    </span><span class="kr">try</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="nv">$Uri</span><span class="w"> </span><span class="nt">-OutputType</span><span class="w"> </span><span class="nx">PSObject</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">Stop</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nv">$</span><span class="nn">script</span><span class="p">:</span><span class="nv">LastError</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$_</span><span class="p">;</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="kr">function</span><span class="w"> </span><span class="nf">Get-IntuneRefusal</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">(</span><span class="bp">$Error</span><span class="n">Record</span><span class="p">)</span><span class="w">
    </span><span class="nv">$out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">pscustomobject</span><span class="p">]@{</span><span class="w"> </span><span class="nx">OuterCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$null</span><span class="p">;</span><span class="w"> </span><span class="nx">InnerCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$null</span><span class="p">;</span><span class="w"> </span><span class="nx">RefusedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="bp">$Error</span><span class="n">Record</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="nv">$out</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="kr">try</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$outer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$Error</span><span class="n">Record.ErrorDetails.Message</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
        </span><span class="nv">$out</span><span class="o">.</span><span class="nf">OuterCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$outer</span><span class="o">.</span><span class="nf">error</span><span class="o">.</span><span class="nf">code</span><span class="w">
        </span><span class="nv">$inner</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$outer</span><span class="o">.</span><span class="nf">error</span><span class="o">.</span><span class="nf">message</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
        </span><span class="nv">$out</span><span class="o">.</span><span class="nf">InnerCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$inner</span><span class="o">.</span><span class="nf">ErrorCode</span><span class="w">
        </span><span class="nv">$body</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$inner</span><span class="o">.</span><span class="nf">Message</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">Message</span><span class="w">
        </span><span class="nx">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$body</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s1">'Url:\s*(https?://[^/?\s]+)'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nv">$out</span><span class="o">.</span><span class="nf">RefusedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$Matches</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w"> </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$out</span><span class="o">.</span><span class="nf">OuterCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$Error</span><span class="n">Record.Exception.Message</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nv">$out</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="nv">$ctx</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-MgContext</span><span class="w">

</span><span class="nv">$dm</span><span class="w">      </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/beta/deviceManagement'</span><span class="w">
</span><span class="nv">$refusal</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-IntuneRefusal</span><span class="w"> </span><span class="nv">$</span><span class="nn">script</span><span class="p">:</span><span class="nv">LastError</span><span class="w">

</span><span class="nv">$skus</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/subscribedSkus'</span><span class="p">)</span><span class="o">.</span><span class="nf">value</span><span class="w">
</span><span class="nv">$roles</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole'</span><span class="p">)</span><span class="o">.</span><span class="nf">value</span><span class="w">

</span><span class="nv">$groupType</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'#microsoft.graph.group'</span><span class="w">
</span><span class="nv">$direct</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@(</span><span class="err">(Get-Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/me/memberOf'</span><span class="p">)</span><span class="o">.</span><span class="nf">value</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="s1">'@odata.type'</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$groupType</span><span class="w"> </span><span class="p">})</span><span class="w">
</span><span class="nv">$trans</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@(</span><span class="err">(Get-Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/me/transitiveMemberOf'</span><span class="p">)</span><span class="o">.</span><span class="nf">value</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="s1">'@odata.type'</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$groupType</span><span class="w"> </span><span class="p">})</span><span class="w">

</span><span class="nv">$result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">pscustomobject</span><span class="p">]@{</span><span class="w">
    </span><span class="nx">Account</span><span class="w">         </span><span class="o">=</span><span class="w"> </span><span class="nv">$ctx</span><span class="err">.</span><span class="nx">Account</span><span class="w">
    </span><span class="nx">AuthType</span><span class="w">        </span><span class="o">=</span><span class="w"> </span><span class="nv">$ctx</span><span class="err">.</span><span class="nx">AuthType</span><span class="w">
    </span><span class="nx">ScopeInToken</span><span class="w">    </span><span class="o">=</span><span class="w"> </span><span class="nv">$ctx</span><span class="err">.</span><span class="nx">Scopes</span><span class="w"> </span><span class="err">-</span><span class="nx">contains</span><span class="w"> </span><span class="s1">'DeviceManagementServiceConfig.Read.All'</span><span class="w">
    </span><span class="nx">IntuneReadable</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">bool</span><span class="p">]</span><span class="nv">$dm</span><span class="w">
    </span><span class="nx">OuterErrorCode</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="nv">$refusal</span><span class="err">.</span><span class="nx">OuterCode</span><span class="w">
    </span><span class="nx">InnerErrorCode</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="nv">$refusal</span><span class="err">.</span><span class="nx">InnerCode</span><span class="w">
    </span><span class="nx">RefusedBy</span><span class="w">       </span><span class="o">=</span><span class="w"> </span><span class="nv">$refusal</span><span class="err">.</span><span class="nx">RefusedBy</span><span class="w">
    </span><span class="nx">TenantHasIntune</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">bool</span><span class="p">]</span><span class="err">(</span><span class="nv">$skus</span><span class="err">.</span><span class="nx">servicePlans</span><span class="w"> </span><span class="err">|</span><span class="w"> </span><span class="nx">Where</span><span class="err">-</span><span class="nx">Object</span><span class="w"> </span><span class="p">{</span><span class="w">
                          </span><span class="bp">$_</span><span class="o">.</span><span class="nf">servicePlanName</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s1">'*INTUNE*'</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">provisioningStatus</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s1">'Success'</span><span class="w"> </span><span class="p">}</span><span class="err">)</span><span class="w">
    </span><span class="nx">AccountLicensed</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">bool</span><span class="p">]</span><span class="err">(</span><span class="nx">Get</span><span class="err">-</span><span class="nx">Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/me/licenseDetails'</span><span class="err">).</span><span class="nx">value</span><span class="w">
    </span><span class="nx">ActiveRoles</span><span class="w">     </span><span class="o">=</span><span class="w"> </span><span class="nv">$roles</span><span class="err">.</span><span class="nx">displayName</span><span class="w"> </span><span class="err">-</span><span class="nx">join</span><span class="w"> </span><span class="s1">', '</span><span class="w">
    </span><span class="nx">InNestedGroup</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="nv">$trans</span><span class="err">.</span><span class="nx">Count</span><span class="w"> </span><span class="err">-</span><span class="nx">gt</span><span class="w"> </span><span class="nv">$direct</span><span class="err">.</span><span class="nx">Count</span><span class="w">
    </span><span class="nx">TenantCreated</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">Get</span><span class="err">-</span><span class="nx">Graph</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/v1.0/organization'</span><span class="err">).</span><span class="nx">value</span><span class="err">.</span><span class="nx">createdDateTime</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="nv">$result</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-List</span><span class="w">

</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$result</span><span class="o">.</span><span class="nf">IntuneReadable</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$next</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Intune is readable. Nothing to fix here.'</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="kr">elseif</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="nv">$result</span><span class="o">.</span><span class="nf">ScopeInToken</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$next</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Reconnect: the scope never made it into the token.'</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="kr">elseif</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="nv">$result</span><span class="o">.</span><span class="nf">TenantHasIntune</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$next</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'No Intune service plan is provisioned in this tenant. Fix licensing, not RBAC.'</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="kr">elseif</span><span class="w"> </span><span class="p">(</span><span class="nv">$result</span><span class="o">.</span><span class="nf">InNestedGroup</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$next</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'You reach your groups only through nesting. Nested members are excluded '</span><span class="w"> </span><span class="o">+</span><span class="w">
            </span><span class="s1">'from unlicensed admin access: add a direct membership, or assign a license.'</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$next</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Token and tenant are fine, so this is entitlement. Add this account to an '</span><span class="w"> </span><span class="o">+</span><span class="w">
            </span><span class="s1">'Entra security group that carries an Intune RBAC role assignment. '</span><span class="w"> </span><span class="o">+</span><span class="w">
            </span><span class="s1">'Read Only Operator covers read-only reporting.'</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="p">[</span><span class="n">pscustomobject</span><span class="p">]@{</span><span class="w"> </span><span class="nx">Verdict</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$next</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-List</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Directory.Read.All</code> and <code class="language-plaintext highlighter-rouge">Organization.Read.All</code> need admin consent on the Microsoft Graph PowerShell app. Without it those fields come back empty and the <code class="language-plaintext highlighter-rouge">IntuneReadable</code> verdict still works.</p>

<p>Against my tenant, with values redacted or shifted where they would identify it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Account         : &lt;upn&gt;
AuthType        : Delegated
ScopeInToken    : True
IntuneReadable  : False
OuterErrorCode  : UnknownError
InnerErrorCode  : Forbidden
RefusedBy       : https://proxy.msubXX.manage.microsoft.com
TenantHasIntune : True
AccountLicensed : False
ActiveRoles     : Global Reader
InNestedGroup   : False
TenantCreated   : 8 March 2022

Verdict : Token and tenant are fine, so this is entitlement. Add this account to an
          Entra security group that carries an Intune RBAC role assignment.
          Read Only Operator covers read-only reporting.
</code></pre></div></div>

<p>That output rules out everything ordinary. The scope is in the token. Intune is provisioned in the tenant. The role is present and currently active - <code class="language-plaintext highlighter-rouge">transitiveMemberOf/microsoft.graph.directoryRole</code> returns only roles that are active right now, so a PIM-eligible role you have not activated will not appear there, which makes that line double as proof my activation landed. Nested group membership, the one exclusion Microsoft documents for unlicensed admin access on <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/licenses#unlicensed-admin-access">both the licensing page</a> and the <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control">RBAC overview</a>, does not apply.</p>

<p>Which leaves the last two lines standing on their own: no license, and nothing granting Intune access in its place.</p>

<h3 id="the-chicken-and-egg-problem">The chicken-and-egg problem</h3>

<p>The obvious next move is to ask Graph what Intune role assignments the account has:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/beta/deviceManagement/roleAssignments'</span><span class="w">
</span></code></pre></div></div>

<p>Which fails with the same <code class="language-plaintext highlighter-rouge">Forbidden</code>, because reading Intune RBAC requires Intune access. You cannot use Graph to diagnose the absence of the very thing that grants you Graph access here, and this is where the script stops being able to help.</p>

<p>There are two ways around it, both outside Graph. The admin center’s <strong>Tenant administration &gt; Roles &gt; My permissions</strong> renders the effective permissions for the signed-in account and needs no Intune permission of its own. And opening <a href="https://intune.microsoft.com">intune.microsoft.com</a> as the same account separates an identity problem from a token or client problem, because the portal and Graph share one RBAC evaluation. It refused me there too, on every workload. At that point you can stop debugging code.</p>

<h2 id="where-microsofts-own-docs-disagree">Where Microsoft’s own docs disagree</h2>

<p>This is the part that cost the most time, and it is not one wrong sentence. It is three statements describing one feature three different ways.</p>

<p><strong>Statement one</strong>, from the <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/licenses">Intune licensing page</a>:</p>

<blockquote>
  <p>“Administrators can sign in to and manage Microsoft Intune without an assigned Intune license. This access is enabled by default for <strong>tenants created after July 2021</strong> and applies to all administrator roles, including Intune administrators and Microsoft Entra administrators.”</p>
</blockquote>

<p>That one is checkable, and the script above already did it. <code class="language-plaintext highlighter-rouge">(Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization').value.createdDateTime</code> returned <strong>8 March 2022</strong> for this tenant - comfortably after the cutoff. So by this page, unlicensed admin access has been on by default since day one here, nobody ever had to flip a toggle, and the documented “up to 48 hours for access changes to take effect” clock is not running either.</p>

<p><strong>Statement two</strong>, from the <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control">RBAC overview</a>, about the same feature:</p>

<blockquote>
  <p>“In June 2021, Intune began supporting unlicensed admins. <strong>User accounts created after this change</strong> can administer Intune without an assigned license.”</p>
</blockquote>

<p>That is not the same gate. One keys off when the <strong>tenant</strong> was created, the other off when the <strong>user account</strong> was created, and the two dates are a month apart.</p>

<p><strong>Statement three</strong> is two paragraphs apart on that same <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/licenses">licensing page</a>:</p>

<blockquote>
  <p>“An Intune license is required for any user or device that benefits directly or indirectly from the Microsoft Intune service, <strong>including access through a Microsoft API</strong>.”</p>
</blockquote>

<blockquote>
  <p>“Unlicensed admin access grants sign-in and management access to <strong>the Microsoft Intune admin center</strong>.”</p>
</blockquote>

<p>Read together, those say unlicensed admin access covers the admin center and that API access needs a license - a significant thing to leave implicit on a page whose headline sentence is “applies to all administrator roles”. It still does not explain my case, because the portal refused me too.</p>

<h3 id="what-the-admin-center-says-instead">What the admin center says instead</h3>

<p>The Intune admin center has its own wording for the same feature, under <strong>Tenant administration &gt; Roles &gt; Administrator Licensing</strong>:</p>

<blockquote>
  <p>“All unlicensed admins have access to Intune. To revoke access from an unlicensed admin, remove them as a member from their <strong>Microsoft Entra group assigned to Intune role</strong>.”</p>
</blockquote>

<p>That names a mechanism none of the Learn pages state: entitlement is carried by group membership, not by holding an admin role.</p>

<p>One documented fact sits behind it. An Intune role is never attached to a person - the assignment always targets a group:</p>

<blockquote>
  <p>“Both Intune custom and built-in roles are assigned to groups of users.” - <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control">RBAC overview</a></p>
</blockquote>

<p>So “a group that carries an Intune role” is the only shape an Intune role assignment can take, and being in that group is the only way to be on the receiving end of one. The licensing page then counts unlicensed admins <strong>per security group</strong> rather than per admin, with a documented limit of 1000, which is what you would expect if the group is what the entitlement travels through.</p>

<p>Which matches what I measured. No such group, no entitlement, so Intune fell back to requiring a license the account did not have.</p>

<h2 id="the-fix">The fix</h2>

<p>Two options, and only two.</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Effect</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Add the account to an Entra security group that carries an Intune RBAC role</td>
      <td>Entitlement arrives with the role assignment</td>
      <td>None</td>
    </tr>
    <tr>
      <td>Assign the account an Intune license</td>
      <td>The existing Global Reader starts working</td>
      <td>A license, and a security problem - see below</td>
    </tr>
  </tbody>
</table>

<p>For read-only reporting, <strong>Read Only Operator</strong> is the right role. Microsoft describes it as <em>“Read Only Operators view user, device, enrollment, configuration and application information and cannot make changes to Intune”</em>, and its <a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control-reference#read-only-operator">permission table</a> grants <code class="language-plaintext highlighter-rouge">Enrollment programs | Read profile</code> - exactly what the Autopilot call needed - alongside <code class="language-plaintext highlighter-rouge">Read device</code> and <code class="language-plaintext highlighter-rouge">Read token</code>, and nothing at all that can write. This is also the role I would have picked over Global Reader in the first place, if the tenant had offered me the choice.</p>

<p>Three details that are easy to get wrong. You <strong>cannot assign an Intune role to a user</strong>, so you are creating or reusing a group whether you wanted to or not. <strong>Direct membership only</strong>: members of nested groups are excluded and still need a license. And <strong>give it time</strong>: Microsoft documents up to 48 hours for unlicensed admin access changes to take effect, so a refusal in the first minutes after adding the group proves nothing yet.</p>

<blockquote>
  <p><strong>ADVICE: do not take the license option.</strong> Admin accounts should be clean: no mailbox, no OneDrive, no Teams presence. That is blast radius, not bureaucracy. An admin account with a mailbox can be phished, can have inbox forwarding rules quietly added to it, shows up in address lists, and gives an attacker who lands on it somewhere to stage data. Most Intune-bearing bundles light all of that up. It trades a five-minute RBAC fix for a permanent increase in what that account is worth to an attacker.</p>
</blockquote>

<h2 id="conclusion">Conclusion</h2>

<p>Intune refusals through Graph lie to you twice. The status line says authentication when the problem is authorization, and the error body hides the one detail that matters - a <code class="language-plaintext highlighter-rouge">manage.microsoft.com</code> host, meaning your token was accepted and the Intune service is what turned you away. Get past those two and the diagnosis takes minutes: ask for <code class="language-plaintext highlighter-rouge">/beta/deviceManagement</code> on its own, open the portal as the same account, and if both turn you away, stop looking at your code and start looking at role assignments.</p>

<p>The deeper trap is that “applies to all administrator roles” reads like a guarantee. Across three Learn statements it is not a consistent one, and none of them mention the mechanism the admin center states outright: entitlement rides on membership of an Entra security group carrying an Intune RBAC role assignment. An Entra admin role on its own buys you nothing in Intune. Give the account a group with <strong>Read Only Operator</strong>, not a license, and keep your admin identities free of mailboxes.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/rksolutions-powershell-module/">RKSolutions PowerShell Module</a> - the module whose <code class="language-plaintext highlighter-rouge">Get-IntuneAnomaliesReport</code> cmdlet hit this <code class="language-plaintext highlighter-rouge">401</code>, alongside the Entra admin roles, license assignment, and enrollment reports.</li>
  <li><a href="/posts/intune-anomalies-report-script/">Intune Anomalies Report</a> - the report itself, now shipped as part of the RKSolutions module.</li>
  <li><a href="/posts/bulk-intune-reporting-graph-export-api/">Bulk Intune Reporting with the Graph Export API</a> - another corner of <code class="language-plaintext highlighter-rouge">deviceManagement</code> where documented behavior and service behavior do not line up.</li>
  <li><a href="/posts/how-to-secure-your-enterprise-application-via-pim/">How to secure your Enterprise Application via PIM</a> - more on using PIM for just-in-time privileged access.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/intune/intune-service/fundamentals/licenses">Microsoft Learn - Microsoft Intune licensing</a></li>
  <li><a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control">Microsoft Learn - Role-based access control with Microsoft Intune</a></li>
  <li><a href="https://learn.microsoft.com/intune/intune-service/fundamentals/role-based-access-control-reference">Microsoft Learn - Built-in role permissions for Microsoft Intune</a></li>
  <li><a href="https://learn.microsoft.com/intune/intune-service/fundamentals/intune-endpoints">Microsoft Learn - Network endpoints for Microsoft Intune</a></li>
  <li><a href="https://learn.microsoft.com/intune/intune-service/fundamentals/assign-role">Microsoft Learn - Assign a role to Intune users</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9110#name-401-unauthorized">RFC 9110 - HTTP Semantics, 401 Unauthorized and 403 Forbidden</a></li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="Entra ID" />
      
        <category term="Graph API" />
      
        <category term="PowerShell" />
      
        <category term="Troubleshooting" />
      

      
      
        <summary type="html"><![CDATA[A Microsoft Graph 401 against Intune is usually not an authentication failure. An unlicensed Global Reader with no Intune RBAC role assignment gets refused by the Intune service itself, in Graph and in the portal alike. Here is the read-only diagnostic script, and the three Microsoft Learn statements that disagree about why.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">Assuming Intune Available Apps Auto-Update? Think Again</title>
      <link href="https://rksolutions.nl/posts/assuming-intune-available-apps-auto-update-think-again/" rel="alternate" type="text/html" title="Assuming Intune Available Apps Auto-Update? Think Again" />
      <published>2026-09-04T00:00:00+00:00</published>
      <updated>2026-09-04T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/assuming-intune-available-apps-auto-update-think-again</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/assuming-intune-available-apps-auto-update-think-again/"><![CDATA[<p>Company Portal says Northstar Workspace 4.3 is ready to install. <strong>Northstar Workspace is a fictional app used for this example.</strong> The laptop already has 4.2. Clicking <strong>Install</strong> sounds harmless: it is only an update, right?</p>

<p>Not necessarily. Company Portal knows that the 4.3 application is not detected. It does <strong>not</strong> automatically know whether its install command upgrades 4.2, replaces it, installs next to it, or fails because the old installation used a different product code, path, or context.</p>

<p>Publishing a newer app keeps the Intune up to date, but it does not define what should happen on a device that already has an older version. That needs an explicit app relationship or installation logic that handles the old state.</p>

<h2 id="the-three-versions-of-the-truth">The three versions of the truth</h2>

<p>The same device can describe Northstar Workspace in three valid but different ways:</p>

<table>
  <thead>
    <tr>
      <th>Where</th>
      <th>What it knows</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Endpoint</td>
      <td>Northstar Workspace 4.2 exists. It may be an old MSI, a manual install, or an earlier Intune deployment whose managed app package was later removed from Intune.</td>
    </tr>
    <tr>
      <td>New 4.3 application</td>
      <td>Its own command, requirements, and evidence for “4.3 is installed.”</td>
    </tr>
    <tr>
      <td>Company Portal</td>
      <td>4.3 is not detected, so it will offer <strong>Install</strong> again.</td>
    </tr>
  </tbody>
</table>

<p>“4.3 is not detected” means only that the 4.3 application’s own detection rule has not passed. It says nothing about an earlier version or related product that may already be on the device.</p>

<blockquote>
  <p><strong>Detection answers whether this application is installed. Supersedence answers what the new application must do with the old one.</strong></p>
</blockquote>

<h2 id="available-is-an-offer-not-an-update-plan">Available is an offer, not an update plan</h2>

<p><strong>Available for enrolled devices</strong> is self-service. Without further configuration, Company Portal shows the new app and the user must choose <strong>Install</strong>.</p>

<p>Publishing 4.3 does not change that. The new app can be offered to the user, but it does not automatically replace or update 4.2.</p>

<h2 id="let-detection-tell-the-truth">Let detection tell the truth</h2>

<p>For this example, configure the 4.3 application’s registry detection rule in the portal as follows:</p>

<table>
  <thead>
    <tr>
      <th>Intune detection setting</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Rule type</td>
      <td>Registry</td>
    </tr>
    <tr>
      <td>Key path</td>
      <td><code class="language-plaintext highlighter-rouge">HKEY_LOCAL_MACHINE\SOFTWARE\Northstar\Workspace</code></td>
    </tr>
    <tr>
      <td>Value name</td>
      <td><code class="language-plaintext highlighter-rouge">DisplayVersion</code></td>
    </tr>
    <tr>
      <td>Detection method</td>
      <td>Version comparison</td>
    </tr>
    <tr>
      <td>Operator</td>
      <td>Greater than or equal to</td>
    </tr>
    <tr>
      <td>Value</td>
      <td><code class="language-plaintext highlighter-rouge">4.3.0</code></td>
    </tr>
    <tr>
      <td>Associated with a 32-bit app on 64-bit clients</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>On this device, <code class="language-plaintext highlighter-rouge">4.2.0 &gt;= 4.3.0</code> is false. That is correct: 4.3 is missing and Company Portal will offer <strong>Install</strong> again.</p>

<blockquote>
  <p><strong>Warning:</strong> Do not weaken version-aware detection just to make Company Portal look friendlier. A rule that accepts every old version hides the fact that the target version is absent.</p>
</blockquote>

<p>A correct failed detection does not make the request a safe upgrade. Intune runs the 4.3 installation process exactly as it was configured. If that process was built only for a clean 4.3 installation, it can run into an existing 4.2 installation with no upgrade, replacement, or removal logic.</p>

<style>
.update-lifecycle-flow{margin:1.9em 0}
.update-lifecycle-flow .flow-stack{display:grid;gap:.65rem;max-width:820px;margin:auto}
.update-lifecycle-flow .flow-node{border:1px solid var(--bd-strong);background:var(--surface);border-radius:12px;padding:.85rem 1rem;text-align:center;line-height:1.45}
.update-lifecycle-flow .flow-node strong{display:block;color:var(--tx)}
.update-lifecycle-flow .flow-node small{display:block;color:var(--tx-low);font-size:.84em}
.update-lifecycle-flow .flow-decision{background:var(--am-bg);border-color:var(--am)}
.update-lifecycle-flow .flow-risk{border-color:var(--am);background:var(--am-bg)}
.update-lifecycle-flow .flow-good{border-color:var(--bd-strong);background:var(--surface)}
.update-lifecycle-flow .flow-arrow{color:var(--am);font-size:1.4em;line-height:1;text-align:center}
.update-lifecycle-flow .flow-outcomes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.8rem;position:relative;padding-top:2rem}
.update-lifecycle-flow .flow-connector{height:calc(2rem + .65rem);left:0;overflow:visible;pointer-events:none;position:absolute;top:-.65rem;width:100%}
.update-lifecycle-flow .flow-connector path{fill:none;stroke:var(--am);stroke-linecap:round;stroke-linejoin:round;stroke-width:2;vector-effect:non-scaling-stroke}
.update-lifecycle-flow .flow-outcome{position:relative}
.update-lifecycle-flow .flow-branch-label{color:var(--tx-low);display:block;font-size:.72em;letter-spacing:.05em;margin-bottom:.55rem;text-align:center;text-transform:uppercase}
.flow-rotate-toast{display:none}
.flow-rotate-toast .flow-rotate-toast-card{max-width:340px;width:min(92vw,340px);border:1px solid var(--am-bd);border-radius:14px;background:var(--surface);box-shadow:0 12px 34px rgba(0,0,0,.35);padding:.95rem .95rem .85rem}
.flow-rotate-toast .flow-rotate-visual{position:relative;width:62px;height:54px;margin:0 auto .55rem}
.flow-rotate-toast .flow-phone{position:absolute;left:20px;top:7px;width:22px;height:38px;border:2px solid var(--am);border-radius:6px;transform-origin:center center;animation:flow-phone-rotate 1.35s ease-in-out infinite}
.flow-rotate-toast .flow-phone::before{content:"";position:absolute;left:50%;bottom:3px;width:6px;height:2px;background:var(--am);transform:translateX(-50%);border-radius:2px}
.flow-rotate-toast .flow-rotate-arrow{position:absolute;right:3px;top:4px;color:var(--am);font-size:1rem;animation:flow-arrow-pulse 1.35s ease-in-out infinite}
.flow-rotate-toast .flow-rotate-text{display:block;text-align:center;color:var(--tx);font-size:.86em;font-weight:600;line-height:1.4}
.flow-rotate-toast .flow-rotate-toast-close{display:block;margin:.7rem auto 0;padding:.28rem .7rem;border-radius:999px;border:1px solid var(--am-bd);background:var(--am-bg);color:var(--tx);font:inherit;font-size:.78em;cursor:pointer}
.flow-rotate-toast .flow-rotate-toast-close:hover{border-color:var(--am);color:var(--am)}
@keyframes flow-phone-rotate{0%{transform:rotate(0deg)}20%{transform:rotate(0deg)}45%{transform:rotate(90deg)}65%{transform:rotate(90deg)}100%{transform:rotate(0deg)}}
@keyframes flow-arrow-pulse{0%,100%{opacity:.45}45%,65%{opacity:1}}
@media(max-width:768px) and (orientation:portrait){
  .update-lifecycle-flow .flow-outcomes{grid-template-columns:1fr;padding-top:0;gap:1.2rem}
  .update-lifecycle-flow .flow-connector{display:none}
  .update-lifecycle-flow .flow-arrow{display:none}
  .flow-rotate-toast.is-active{
    display:flex;
    align-items:flex-end;
    justify-content:center;
    position:fixed;
    inset:0;
    z-index:250;
    padding:1rem;
    background:rgba(0,0,0,.38);
    backdrop-filter:blur(2px);
  }
}
</style>

<figure class="update-lifecycle-flow" role="group" aria-labelledby="update-flow-caption">
  <div class="flow-stack">
    <div class="flow-node"><strong>Device has Northstar Workspace 4.2</strong><small>Old state can be manual, user-context, or System-context.</small></div>
    <div class="flow-arrow" aria-hidden="true">↓</div>
    <div class="flow-node flow-decision"><strong>4.3 is published as Available</strong><small>Its 4.3 detection fails, so Company Portal will offer Install.</small></div>
    <div class="flow-arrow" aria-hidden="true">↓</div>
    <div class="flow-node flow-decision"><strong>How is 4.3 related to 4.2?</strong></div>
    <div class="flow-outcomes">
      <svg class="flow-connector" viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true">
        <path d="M500 0V42Q500 55 487 55H180Q167 55 167 68V100M500 0V100M500 42Q500 55 513 55H820Q833 55 833 68V100" />
      </svg>
      <div class="flow-outcome">
        <div class="flow-node flow-risk"><span class="flow-branch-label">No supersedence</span><strong>User clicks Install</strong><small>4.3 runs against unmodelled state: possible conflict, side-by-side install, failure, or incomplete replacement.</small></div>
      </div>
      <div class="flow-outcome">
        <div class="flow-node"><span class="flow-branch-label">Supersedence, Auto-update off</span><strong>User clicks Install</strong><small>The relationship is explicit, but Available remains a manual choice.</small></div>
      </div>
      <div class="flow-outcome">
        <div class="flow-node flow-good"><span class="flow-branch-label">Supersedence, Auto-update on</span><strong>Intune starts the update</strong><small>No click, provided Company Portal consent and assignment conditions remain valid.</small></div>
      </div>
    </div>
  </div>
  <figcaption id="update-flow-caption">Supersedence models the 4.2-to-4.3 transition. Intune's Available Auto-update setting decides whether a Company Portal click is still needed.</figcaption>
</figure>
<div class="flow-rotate-toast" id="flow-rotate-toast" role="status" aria-live="polite" aria-atomic="true">
  <div class="flow-rotate-toast-card">
    <div class="flow-rotate-visual" aria-hidden="true">
      <span class="flow-phone"></span>
      <span class="flow-rotate-arrow">↻</span>
    </div>
    <span class="flow-rotate-text">Rotate your phone to landscape for the full branch flow.</span>
    <button class="flow-rotate-toast-close" type="button" aria-label="Dismiss rotate tip">Got it</button>
  </div>
</div>
<script>
(() => {
  const flow = document.querySelector('.update-lifecycle-flow');
  const toast = document.getElementById('flow-rotate-toast');
  if (!flow || !toast) return;

  const mobilePortrait = window.matchMedia('(max-width: 768px) and (orientation: portrait)');
  let shown = false;

  const hideToast = () => toast.classList.remove('is-active');
  const showToast = () => {
    if (!mobilePortrait.matches || shown) return;
    toast.classList.add('is-active');
    shown = true;
    window.setTimeout(hideToast, 7000);
  };

  const closeBtn = toast.querySelector('.flow-rotate-toast-close');
  if (closeBtn) closeBtn.addEventListener('click', hideToast);

  if (mobilePortrait.addEventListener) {
    mobilePortrait.addEventListener('change', (event) => {
      if (!event.matches) hideToast();
    });
  }

  if (!('IntersectionObserver' in window)) {
    showToast();
    return;
  }

  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        showToast();
        observer.disconnect();
        break;
      }
    }
  }, { threshold: 0.08, rootMargin: "0px 0px -12% 0px" });

  observer.observe(flow);
})();
</script>

<h2 id="supersedence-makes-the-transition-explicit">Supersedence makes the transition explicit</h2>

<p>On the 4.3 Win32 app, create a supersedence relationship to 4.2. Then decide whether this is an <strong>update</strong> or a <strong>replacement</strong>:</p>

<ul>
  <li><strong>Update:</strong> the 4.3 installer upgrades 4.2 itself. Leave <strong>Uninstall previous version</strong> off.</li>
  <li><strong>Replacement:</strong> Intune removes 4.2, then installs 4.3. Enable <strong>Uninstall previous version</strong> and test both commands in their intended context.</li>
</ul>

<h2 id="available-auto-update">Available Auto-update</h2>

<p>Configure the no-click path on the newer Win32 application, following <a href="https://learn.microsoft.com/en-us/intune/app-management/deployment/configure-win32-supersedence">Microsoft’s documented flow</a>:</p>

<ol>
  <li>In <strong>Apps &gt; All apps</strong>, add or open the newer Win32 application, such as 4.3.</li>
  <li>On its <strong>Supersedence</strong> step, add the older application, such as 4.2.</li>
  <li>On <strong>Assignments</strong>, create or open the intended <strong>Available for enrolled devices</strong> assignment. Its target can be a group, <strong>All users</strong>, or <strong>All devices</strong>.</li>
  <li>In that assignment’s <strong>Auto-update</strong> column, select <strong>Auto-update</strong>, then save the assignment and application.</li>
  <li>Keep the original Available assignment and its scope in place. The original Company Portal user must still be signed in, and the device or user must remain in scope, whether that is through a group, <strong>All users</strong>, or <strong>All devices</strong>. Changing the assignment or intent after the original Company Portal install removes the consent state that Auto-update relies on.</li>
</ol>

<p>This applies when the user originally installed 4.2 from Company Portal. With that state intact, Intune requires 4.3 without another Company Portal click.</p>

<h2 id="when-an-app-management-platform-publishes-your-apps">When an app-management platform publishes your apps</h2>

<p>An app-management platform adds real value when it can keep this lifecycle intact as it publishes a new release: reliable version detection, supersedence, the correct update or replacement behaviour, and the Available <strong>Auto-update</strong> assignment. That removes repetitive configuration work and makes the intended transition consistent.</p>

<p>Do not assume it does this just because it publishes a newer version. Verify what the platform creates in Intune. If it only publishes the next package, Intune sees a new application rather than a known successor. The platform must either support supersedence and Auto-update, or ensure the package handles legacy state before it installs.</p>

<h2 id="test-the-lifecycle-not-just-a-clean-install">Test the lifecycle, not just a clean install</h2>

<p>Use an enrolled test device and publish version 2 exactly as production automation will. Do not test only a clean installation. Test these two paths:</p>

<ol>
  <li><strong>Known Company Portal install:</strong> install version 1 through Company Portal, then verify that supersedence with Auto-update moves it to version 2 without another user action.</li>
  <li><strong>Existing unmanaged installation:</strong> start with an old manual, user-context, or unrelated installation. Verify that the version-2 package safely upgrades, replaces, removes, or stops when it encounters that state.</li>
</ol>

<p>In both cases, confirm that version 2 is detected and that a device without version 1 does not receive an unwanted first install. If the result is unclear, inspect <code class="language-plaintext highlighter-rouge">AppWorkload.log</code> and <code class="language-plaintext highlighter-rouge">AppActionProcessor.log</code>.</p>

<h2 id="the-practical-rule">The practical rule</h2>

<p><strong>Available supersedence with Auto-update</strong> solves the central Company Portal problem when the earlier version is a known Intune application installed from Company Portal. It gives Intune both the relationship and the instruction to install the successor.</p>

<blockquote>
  <p><strong>Important:</strong> Supersedence links Intune applications. It does not make <code class="language-plaintext highlighter-rouge">HKLM</code> and <code class="language-plaintext highlighter-rouge">HKCU</code> equivalent. A System-context installer uses the System account’s <code class="language-plaintext highlighter-rouge">HKCU</code>; user-context logic can miss a device-wide <code class="language-plaintext highlighter-rouge">HKLM</code> installation. If both histories are possible, the installer or wrapper must check the relevant contexts and deliberately upgrade, replace, remove, or stop.</p>
</blockquote>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/intune/app-management/deployment/configure-win32-supersedence">Add Win32 app supersedence and use auto-update with app supersedence</a></li>
  <li><a href="https://learn.microsoft.com/en-us/intune/app-management/deployment/add-win32">Add and assign Win32 apps in Microsoft Intune</a></li>
  <li><a href="https://learn.microsoft.com/en-us/intune/device-management/tools/management-extension-windows">Understand the Intune Management Extension</a></li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="Company Portal" />
      
        <category term="Win32" />
      
        <category term="App Management" />
      

      
      
        <summary type="html"><![CDATA[Learn how Intune Win32 supersedence and Available Auto-update prevent Company Portal updates from colliding with older app installations and legacy state.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">InforcerCommunity MCP: Ask Your AI Assistant About Your Inforcer Tenants</title>
      <link href="https://rksolutions.nl/posts/inforcercommunity-mcp/" rel="alternate" type="text/html" title="InforcerCommunity MCP: Ask Your AI Assistant About Your Inforcer Tenants" />
      <published>2026-09-02T00:00:00+00:00</published>
      <updated>2026-09-02T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/inforcercommunity-mcp</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/inforcercommunity-mcp/"><![CDATA[<p>A while back I published the <a href="/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api/">InforcerCommunity PowerShell module</a>, which wraps the Inforcer REST API in cmdlets. This is the other half of that idea.</p>

<p><strong>InforcerCommunity MCP</strong> connects an AI tool to the Inforcer REST API, so you can ask about your own tenant data in plain language:</p>

<p><em>“Which of my tenants are furthest from their baseline?”</em><br />
<em>“In Contoso, which policy controls password expiry?”</em><br />
<em>“Do all of my customers block legacy authentication?”</em></p>

<p>Your AI tool cannot normally see any of that, and this connects the two.</p>

<p>InforcerCommunity MCP was created by me for the community. It is an independent, community-driven project to make the Inforcer REST API easier to reach from the AI tools you already work in, and it is <strong>not owned, endorsed, or maintained by Inforcer</strong>.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#what-is-an-mcp-server">What is an MCP server?</a></li>
  <li><a href="#what-you-can-ask-about-your-inforcer-tenants">What you can ask about your Inforcer tenants</a></li>
  <li><a href="#finding-a-setting-when-you-do-not-know-the-policy-name">Finding a setting when you do not know the policy name</a></li>
  <li><a href="#read-only-by-default">Read-only by default</a></li>
  <li><a href="#where-your-data-goes">Where your data goes</a></li>
  <li><a href="#installing-the-inforcercommunity-mcp-server">Installing the InforcerCommunity MCP server</a></li>
  <li><a href="#mcp-or-the-powershell-module-which-one-do-i-use">MCP or the PowerShell module: which one do I use?</a></li>
  <li><a href="#links">Links</a></li>
</ul>

<video controls="" preload="metadata" style="width:100%;max-width:856px;border-radius:10px;margin:1em 0" aria-label="Claude Desktop answering a question about device encryption policies in an Inforcer tenant, using the InforcerCommunity MCP server">
  <source src="/assets/videos/2026-09/inforcercommunity-mcp-demo.mp4" type="video/mp4" />
  Your browser cannot play this video.
</video>

<h2 id="what-is-an-mcp-server">What is an MCP server?</h2>

<p>MCP stands for Model Context Protocol. It is a standard way of giving an AI tool access to a system it would otherwise know nothing about. You install a small “program”, your AI tool discovers what it can do, and from then on it can fetch real data instead of guessing at it.</p>

<p>That is all this is, and on your side it is really just configuration: one block in your AI tool’s settings, pointing at something that speaks the Inforcer REST API on one side and MCP on the other. There is nothing to download by hand, and you never call it yourself. You ask your AI tool a question, it works out which part of the Inforcer REST API answers that question, and the answer comes back in the conversation.</p>

<p>It runs on your own machine. Your AI tool starts it when it launches and stops it when you close it, so there is nothing hosted and no address to connect to.</p>

<h2 id="what-you-can-ask-about-your-inforcer-tenants">What you can ask about your Inforcer tenants</h2>

<p>Most of what Inforcer holds is reachable: tenants, baselines, policies, alignment scores and details, users, groups, roles, secure scores, the activity log, reports and assessments.</p>

<p>Worth setting expectations on first: <strong>the MCP only supports what the Inforcer REST API supports.</strong> It is a wrapper, not a second source of data. If something is visible in the portal but has no API endpoint behind it, you cannot ask about it here either.</p>

<p>Within that, you never have to learn a command or look up an ID. Use tenant names like “Contoso” and ask for what you want:</p>

<table>
  <thead>
    <tr>
      <th>Ask something like</th>
      <th>And you get back</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>“List my tenants and their alignment scores”</td>
      <td>Every tenant with its baseline alignment percentage and its secure score</td>
    </tr>
    <tr>
      <td>“Where does Contoso deviate from its assigned baseline?”</td>
      <td>Each policy in that tenant that no longer matches its baseline, and what differs</td>
    </tr>
    <tr>
      <td>“Show me Contoso’s Conditional Access policies”</td>
      <td>The CA policies in that tenant with their state, conditions and grant controls</td>
    </tr>
    <tr>
      <td>“Which policy in Contoso controls password expiry?”</td>
      <td>The policy that actually holds that setting, and the value it is set to</td>
    </tr>
    <tr>
      <td>“Who holds Global Administrator in Contoso?”</td>
      <td>The members of that role, with their display names and sign-in names</td>
    </tr>
    <tr>
      <td>“What licences does jane@contoso.com have?”</td>
      <td>That user’s assigned licences, plus their groups, roles and devices</td>
    </tr>
    <tr>
      <td>“What’s Contoso’s secure score, and is it improving?”</td>
      <td>The current score out of its maximum, with the history behind it</td>
    </tr>
    <tr>
      <td>“What changed in Contoso in the last 30 days?”</td>
      <td>Activity log entries for that period, filtered by event type</td>
    </tr>
  </tbody>
</table>

<p>What makes it useful is that it hands your AI tool the actual numbers and policy data, not a pre-written answer. The tool does the interpreting, so a follow-up question about the same data gets you something genuinely different back rather than the same paragraph reworded.</p>

<p>Ask it to list your tenants with their alignment scores, and you get the numbers:</p>

<table>
  <thead>
    <tr>
      <th>Client</th>
      <th>Baseline alignment</th>
      <th>Secure score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Contoso</td>
      <td>91.5%</td>
      <td>412 / 600</td>
    </tr>
    <tr>
      <td>Fabrikam</td>
      <td>80.9%</td>
      <td>388 / 600</td>
    </tr>
    <tr>
      <td>Northwind</td>
      <td>57.9%</td>
      <td>301 / 600</td>
    </tr>
  </tbody>
</table>

<p>Then ask which three you should review first, and it reasons over the data it already has: Northwind first, because 57.9% is the widest gap in the portfolio, then Fabrikam, which has drifted since its last alignment run. No second round of API calls, and no answer you could have written in advance.</p>

<p>It is also multilingual, and that comes for free rather than as a feature. Because what comes back is data rather than finished sentences, your AI tool answers in whatever language you asked in. A question in Dutch gets a Dutch answer. Policy names stay exactly as Inforcer reads them.</p>

<p>There are more examples, and the awkward cases, in the <a href="https://github.com/royklo/inforcercommunity-mcp/blob/main/docs/EXAMPLES.md">examples guide</a>.</p>

<h2 id="finding-a-setting-when-you-do-not-know-the-policy-name">Finding a setting when you do not know the policy name</h2>

<p>You do not need to know what a policy is called to find it. Describe the setting you are after and ask whether anything in the tenant configures it:</p>

<p><em>“Is there anything in Contoso that controls password expiry?”</em><br />
<em>“Which policy in Fabrikam blocks legacy authentication?”</em></p>

<p>Phrased that way, your AI tool searches inside the policy settings and comes back with whichever policy holds it, whatever name it happens to have.</p>

<p>That is the useful habit to get into, because policy names rarely describe what is inside them. A policy that turns off passkeys might be called anything at all. So if you search by name and get nothing back, the only thing you have learned is that <strong>no policy has that name</strong> - not that the setting is missing. Ask what a policy does rather than what it is called, and you get a real answer.</p>

<h2 id="read-only-by-default">Read-only by default</h2>

<p>The MCP ships read-only. Everything that could change something in a tenant is refused, so there is nothing you can accidentally reconfigure by phrasing a question badly.</p>

<p>There is also nothing to switch on today. The Inforcer REST API offers no way to change a client’s settings, so neither does this. The setting that would permit writes exists and is listed in the <a href="https://github.com/royklo/inforcercommunity-mcp/blob/main/docs/SETUP.md#optional-settings">setup guide</a>, but until the API grows those endpoints it does nothing - and enabling it would stay a human decision at a file on your own machine rather than something an AI tool could choose for itself.</p>

<h2 id="where-your-data-goes">Where your data goes</h2>

<p>Nothing is hosted, and the only outbound calls are to the Inforcer REST API.</p>

<p>How your API key is stored depends on how you set it up. The VS Code one-click button hands it to VS Code’s own password store rather than writing it into a file, and it never reaches your browser history either. With the JSON block the key lives in your AI tool’s settings file, and the <a href="https://github.com/royklo/inforcercommunity-mcp/blob/main/docs/SETUP.md#two-inforcer-accounts">accounts file option</a> exists for keeping it out of the file most likely to get synced between machines or pasted into a support ticket.</p>

<p>The part worth understanding is the path your client data travels, because there is no anonymisation anywhere along it. When you ask a question, the MCP calls the Inforcer REST API, Inforcer pulls the data from the tenant at that moment, and it comes back through the MCP into your AI tool’s context. From there it goes on to your AI provider and into your AI tool’s own saved conversation history on disk. Real tenant names, real user names, real email addresses, at every one of those hops.</p>

<p>The full detail, including what is not promised, is in <a href="https://github.com/royklo/inforcercommunity-mcp/blob/main/docs/SECURITY.md">SECURITY.md</a>.</p>

<h2 id="installing-the-inforcercommunity-mcp-server">Installing the InforcerCommunity MCP server</h2>

<p>You need two things: an Inforcer API key from the portal, and the region that key belongs to (<code class="language-plaintext highlighter-rouge">uk</code>, <code class="language-plaintext highlighter-rouge">eu</code>, <code class="language-plaintext highlighter-rouge">us</code> or <code class="language-plaintext highlighter-rouge">anz</code>). A key works in exactly one region, and the wrong region fails in a way that looks identical to a wrong key, so check that first if nothing works. It runs on every platform.</p>

<p>In VS Code it is a one-click button in the <a href="https://github.com/royklo/inforcercommunity-mcp">README</a>. For Claude Code it is one command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp add inforcer <span class="nt">-s</span> user <span class="se">\</span>
  <span class="nt">-e</span> <span class="nv">INFORCER_API_KEY</span><span class="o">=</span>your-api-key <span class="se">\</span>
  <span class="nt">-e</span> <span class="nv">INFORCER_REGION</span><span class="o">=</span>uk <span class="se">\</span>
  <span class="nt">--</span> npx <span class="nt">-y</span> inforcercommunity-mcp@latest
</code></pre></div></div>

<p>For Claude Desktop, Cursor and more it is one block in the settings file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"inforcer"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npx"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-y"</span><span class="p">,</span><span class="w"> </span><span class="s2">"inforcercommunity-mcp@latest"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"INFORCER_API_KEY"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-api-key"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"INFORCER_REGION"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uk"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then restart your AI tool, because it only reads its settings when it starts, and ask it to run <code class="language-plaintext highlighter-rouge">inforcer_status</code>. If that comes back with <code class="language-plaintext highlighter-rouge">"state": "ok"</code>, you are done. Ask it to list your tenants and you should see them.</p>

<p>You do not have to maintain any of this. Because the config points at <code class="language-plaintext highlighter-rouge">@latest</code>, every new version I ship is picked up on its own the next time your AI tool starts, so fixes and newly supported endpoints arrive without you touching the file. Updates land at startup rather than mid-session, so if you have had the same session open for days, restart it to get the newest one.</p>

<p>Everything past that point lives in the repo: which AI tools are supported and where each one keeps its settings, the optional settings, the per-platform notes, and how to run two Inforcer accounts side by side are all in the <a href="https://github.com/royklo/inforcercommunity-mcp/blob/main/docs/SETUP.md">setup guide</a>.</p>

<h2 id="mcp-or-the-powershell-module-which-one-do-i-use">MCP or the PowerShell module: which one do I use?</h2>

<p>The MCP answers questions. The PowerShell module produces artefacts. Full tenant documentation, a side-by-side Intune comparison, a multi-tenant assessment matrix or a scheduled csv are all module jobs, and the MCP will tell you so rather than trying.</p>

<p>The rough rule: if the output belongs in a conversation, ask the MCP. If it belongs in a file you hand to somebody else, run the module.</p>

<h2 id="links">Links</h2>

<ul>
  <li><strong>GitHub:</strong> <a href="https://github.com/royklo/inforcercommunity-mcp">https://github.com/royklo/inforcercommunity-mcp</a></li>
  <li><strong>PowerShell module:</strong> <a href="/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api/">blog post</a> and <a href="https://github.com/royklo/InforcerCommunity">repo</a></li>
</ul>

<p>Bugs and feature requests go in the <a href="https://github.com/royklo/inforcercommunity-mcp/issues">issues</a>. Issues are public, so never paste your key, and strip client names first. The most useful report you can send is a question that got a bad answer, because that almost always means a tool description is wrong.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api/">InforcerCommunity: A PowerShell Module for the Inforcer REST API</a> - the other half of this idea, for when you need a file rather than an answer.</li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Entra ID" />
      
        <category term="Intune" />
      
        <category term="Reports" />
      
        <category term="Tools" />
      
        <category term="AI" />
      

      
      
        <summary type="html"><![CDATA[InforcerCommunity MCP is a community-built MCP server for the Inforcer REST API. Ask your AI assistant which tenants are furthest from their baseline, which policy controls a setting, or who holds Global Administrator. Runs on your own machine, read-only by default.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">The Intune Assignment Filter Boundary That Only Exists in the UI</title>
      <link href="https://rksolutions.nl/posts/intune-cross-platform-assignment-filters/" rel="alternate" type="text/html" title="The Intune Assignment Filter Boundary That Only Exists in the UI" />
      <published>2026-08-21T00:00:00+00:00</published>
      <updated>2026-08-21T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/intune-cross-platform-assignment-filters</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/intune-cross-platform-assignment-filters/"><![CDATA[<p>Targeting is where Intune is won or lost, and assignment filters are one of the best things to happen to it. A dynamic group can express most of what you need, but you wait for it: membership is evaluated in the background, and a freshly enrolled device sits outside the group until that catches up. A filter is evaluated against the device’s own properties at assignment time, so there is nothing to wait for.</p>

<p>The real strength is that filters do not replace groups, they stack on top of them. Assign to a dynamic group and filter within it. Or assign to a group of users and filter on device properties, so a policy follows people around but only ever lands on the hardware you meant. That is an extra layer of flexibility over everything you already have.</p>

<p>Every filter is also bound to a platform. That boundary turns out to live in exactly one place: the dropdown you pick it from.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#what-an-assignment-filter-gives-you">What an assignment filter gives you</a></li>
  <li><a href="#the-one-thing-the-picker-will-not-offer">The one thing the picker will not offer</a></li>
  <li><a href="#how-a-mismatched-filter-breaks-your-targeting">How a mismatched filter breaks your targeting</a></li>
  <li><a href="#the-portal-shows-it-then-writes-it-back">The portal shows it, then writes it back</a></li>
  <li><a href="#finding-it-in-your-own-tenant">Finding it in your own tenant</a></li>
  <li><a href="#the-screenshots">The screenshots</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
</ul>

<h2 id="what-an-assignment-filter-gives-you">What an assignment filter gives you</h2>

<p>A filter is a rule over <a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters-device-properties">device properties</a>, written once and reused on as many <a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters-supported-workloads">assignments</a> as you like. You point a policy at a group, then layer the filter on that assignment to narrow it: include only the devices matching the rule, or exclude the ones that do.</p>

<p>What makes it powerful is that the group and the filter answer different questions. The group says <em>who</em>, the filter says <em>on what</em>. So you can say things like:</p>

<ul>
  <li>Apply this to everyone in Marketing, but only on their personal devices, so it never lands on a shared or kiosk machine</li>
  <li>Apply this only to devices from one enrollment profile, so everything enrolled through the others is left alone</li>
  <li>Keep Cloud PCs out of an assignment that would otherwise sweep them up</li>
</ul>

<p>None of those need a new group. You are not restructuring anything, you are adding a condition to an assignment you already have.</p>

<p>Every filter carries a <a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters">platform</a>, set when you create it. That platform is not cosmetic: it decides which properties the rule editor offers you, and it decides which filters appear when you assign a policy.</p>

<p><a href="#the-screenshots"><strong>Screenshot 1</strong></a> - Both filters in the tenant list, with their Platform column.</p>

<p>For this post I built a small scaffold in a lab tenant: two filters, two empty groups and two Settings Catalog policies. The macOS filter matches <code class="language-plaintext highlighter-rouge">(device.manufacturer -eq "Apple")</code> and the Windows one matches <code class="language-plaintext highlighter-rouge">(device.manufacturer -eq "Dell Inc.")</code>. Both rules deliberately use <code class="language-plaintext highlighter-rouge">device.manufacturer</code>. Remember that.</p>

<h2 id="the-one-thing-the-picker-will-not-offer">The one thing the picker will not offer</h2>

<p>Open the Windows policy, go to its assignment, and open the filter picker. Only the Windows filters are listed. The macOS filter is not there and there is no way to select it. That is correct, and it is clearly intentional: the Microsoft admin center is deliberately shielding you from a combination that should not exist.</p>

<p>Graph does not participate in that. It never receives the constraint in the first place, so it has nothing to check against and simply carries out the request. An assignment expresses a filter as <a href="https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfigv2-devicemanagementconfigurationpolicyassignment?view=graph-rest-beta">two properties on the target</a>, an ID and a mode, and nothing in the payload mentions a platform at all:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$body = @{
    assignments = @(
        @{
            target = @{
                '@odata.type'                              = '#microsoft.graph.groupAssignmentTarget'
                groupId                                    = $groupId
                deviceAndAppManagementAssignmentFilterId   = $macOSFilterId
                deviceAndAppManagementAssignmentFilterType = 'include'
            }
        }
    )
}

# $policyId here is a policy with platforms = windows10
Invoke-MgGraphRequest -Method POST `
    -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$policyId/assign" `
    -Body ($body | ConvertTo-Json -Depth 100) -ContentType 'application/json'
</code></pre></div></div>

<p>It is on Microsoft Intune to check whether that filter belongs on that policy. It does not check. The call returns success, and more importantly the value is still there when you read it back. That distinction matters, because an API that accepts a value and silently drops it is its own kind of bug and looks identical from the outside.</p>

<p><a href="#the-screenshots"><strong>Screenshot 2</strong></a> - Four cross-platform assignments, all accepted, all still there after a read-back.</p>

<p>Here is one of the two assignments on <code class="language-plaintext highlighter-rouge">FilterDemo - Windows Security Baseline</code>, a policy whose <code class="language-plaintext highlighter-rouge">platforms</code> value is <code class="language-plaintext highlighter-rouge">windows10</code>, where <code class="language-plaintext highlighter-rouge">983bb135-...</code> is the macOS filter:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"@odata.type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#microsoft.graph.groupAssignmentTarget"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"deviceAndAppManagementAssignmentFilterId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"983bb135-92ad-4155-8807-7664008466ec"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"deviceAndAppManagementAssignmentFilterType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"include"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"groupId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"83bdd559-ae02-44f4-a31e-a0f3df155234"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="how-a-mismatched-filter-breaks-your-targeting">How a mismatched filter breaks your targeting</h2>

<p>It would be easy to shrug this off. A macOS filter can never match a Windows device, so surely it is inert?</p>

<p>It is not, and this is the part I found most interesting. Graph will tell you which properties each platform supports, through <a href="https://learn.microsoft.com/en-us/graph/api/intune-policyset-deviceandappmanagementassignmentfilter-getplatformsupportedproperties"><code class="language-plaintext highlighter-rouge">getPlatformSupportedProperties</code></a>, and the two lists almost completely overlap:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Properties</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Shared by both</td>
      <td><code class="language-plaintext highlighter-rouge">deviceName</code>, <code class="language-plaintext highlighter-rouge">manufacturer</code>, <code class="language-plaintext highlighter-rouge">model</code>, <code class="language-plaintext highlighter-rouge">deviceCategory</code>, <code class="language-plaintext highlighter-rouge">osVersion</code>, <code class="language-plaintext highlighter-rouge">operatingSystemVersion</code>, <code class="language-plaintext highlighter-rouge">deviceOwnership</code>, <code class="language-plaintext highlighter-rouge">enrollmentProfileName</code>, <code class="language-plaintext highlighter-rouge">cpuArchitecture</code></td>
    </tr>
    <tr>
      <td>macOS only</td>
      <td><code class="language-plaintext highlighter-rouge">isDeviceAttested</code></td>
    </tr>
    <tr>
      <td>Windows only</td>
      <td><code class="language-plaintext highlighter-rouge">isRooted</code>, <code class="language-plaintext highlighter-rouge">isTpmAttested</code>, <code class="language-plaintext highlighter-rouge">deviceTrustType</code>, <code class="language-plaintext highlighter-rouge">operatingSystemSKU</code></td>
    </tr>
  </tbody>
</table>

<p>Nine of the ten properties a macOS filter can use also exist on Windows. The <code class="language-plaintext highlighter-rouge">platform</code> value on a filter is not a constraint on the rule language, it is metadata that drives the picker. A rule written for one platform is, in the vast majority of cases, perfectly valid syntax on the other.</p>

<p>That changes the failure mode. A cross-platform filter is not dead weight Intune ignores, it is a real scope control sitting on your assignment where you cannot see how it got there. And the two modes fail in opposite directions:</p>

<ul>
  <li>An <strong>include</strong> filter narrows an assignment to whatever its rule matches. Aim one at the wrong platform and there is nothing for it to match, on a policy that still reports as assigned.</li>
  <li>An <strong>exclude</strong> filter carves devices out of an assignment. Aim one at the wrong platform and there is nothing to carve, on a policy you believe has an exception in place.</li>
</ul>

<p>Neither announces itself.</p>

<h2 id="the-portal-shows-it-then-writes-it-back">The portal shows it, then writes it back</h2>

<p>This is where it stops being an API curiosity. Here is that Windows policy with two assignments: a correctly matched Windows filter on one group, and the macOS filter on another.</p>

<p><a href="#the-screenshots"><strong>Screenshot 3</strong></a> - Platform reads Windows. One row carries a Windows filter, the other a macOS filter. Same table, same presentation, both Active.</p>

<h3 id="it-renders-as-a-healthy-assignment">It renders as a healthy assignment</h3>

<p>No warning, no badge, no greyed-out row. Both are listed by name with a status of Active. Nothing in the admin center distinguishes the assignment you are allowed to make from the one you are not.</p>

<p>Open the filter panel on that second assignment and the whole contradiction fits on one screen. The mode radio is correctly pre-selected from the API value. The list below it does not contain the filter that is currently applied. And at the bottom the portal holds the macOS filter in its <strong>Selected filter</strong> box, offering only <strong>Remove</strong>.</p>

<p><a href="#the-screenshots"><strong>Screenshot 4</strong></a> - The picker lists only Windows filters, while the Selected filter box below it holds a macOS filter, offering only Remove.</p>

<p>Look at the rules in that panel. The offered Windows filter reads <code class="language-plaintext highlighter-rouge">(device.manufacturer -eq "Dell Inc.")</code> and the retained macOS filter reads <code class="language-plaintext highlighter-rouge">(device.manufacturer -eq "Apple")</code>. Same property, same syntax, same rule language. The only thing separating them is a platform label that the picker honours and nothing else does.</p>

<h3 id="it-writes-the-mismatch-back">It writes the mismatch back</h3>

<p>So the next thing to test was what the portal does if you simply re-apply that assignment from the UI. Not editing it, not fixing it, just walking the same path an admin walks on any normal change: open the assignment, open the filter panel, leave everything as it is, <strong>Select</strong>, then <strong>Review + save</strong>.</p>

<p><a href="#the-screenshots"><strong>Screenshot 5</strong></a> - Saving a Windows policy whose assignment carries a macOS filter. The portal is satisfied.</p>

<p>That was not a UI-only no-op. The audit log records a write, attributed to the portal itself:</p>

<p><a href="#the-screenshots"><strong>Screenshot 6</strong></a> - The audit event for that save. Every old and new value is identical, and the write is attributed to the Microsoft Intune portal extension.</p>

<p>Every property in that event has an identical old and new value. Nothing changed, nothing needed changing, and the portal patched it anyway. I ran the harder version on a throwaway policy too, changing only the mode from include to exclude, and there the audit event showed the macOS filter’s own ID re-submitted on both sides. The portal does not merely tolerate the value, it hands it back to the API.</p>

<h2 id="finding-it-in-your-own-tenant">Finding it in your own tenant</h2>

<p>This is trivially detectable, just not from the admin center. There is no column, filter or report that surfaces it, so you have to ask Graph: pull every policy with its assignments, pull every filter, join them on the filter ID and compare the two platform values.</p>

<p>I wrapped that up as a read-only script. It creates nothing, changes nothing and deletes nothing, it just reports. It handles the two things that trip up a first attempt: filter platforms and policy platforms use different vocabularies, so a plain string comparison reports false positives on perfectly healthy assignments, and a deleted filter leaves its ID behind on the assignment so the lookup misses rather than resolving.</p>

<p><strong><a href="/assets/scripts/Find-IntuneCrossPlatformFilters.ps1">Download Find-IntuneCrossPlatformFilters.ps1</a></strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./Find-IntuneCrossPlatformFilters.ps1
</code></pre></div></div>

<p>It needs the <code class="language-plaintext highlighter-rouge">Microsoft.Graph.Authentication</code> module and the <code class="language-plaintext highlighter-rouge">DeviceManagementConfiguration.Read.All</code> scope. Worth adding to whatever tenant health check you already run.</p>

<h2 id="the-screenshots">The screenshots</h2>

<p>Use the arrows or dots to step through, and the expand icon for a full-screen view.</p>

<div class="post-gallery">
  <div class="gallery-track">
    <img src="/assets/images/2026-08/filters-list.png" alt="Intune admin center list of assignment filters, showing a macOS filter and a Windows 10 and later filter highlighted among existing filters, with a Platform column." />
    <img src="/assets/images/2026-08/script-verdict.png" alt="PowerShell terminal output showing four cross-platform filter assignment tests, each accepted and persisted, followed by a verdict table with HttpAccepted and Persisted both true for all four rows." />
    <img src="/assets/images/2026-08/windows-policy-both-assignments.png" alt="Intune configuration policy overview showing Platform Windows, and an Included groups table with two rows, one filtered by a macOS filter and one by a Windows filter, both with status Active." />
    <img src="/assets/images/2026-08/auto-selected-wrong-filter-and-can-still-save.png" alt="Intune assignment filter panel showing a picker list containing only Windows platform filters, while a Selected filter section at the bottom shows a macOS filter with a Remove link." />
    <img src="/assets/images/2026-08/assignment-implemented-successfully.png" alt="Intune notification toast reading Policy updated, Assignments saved successfully." />
    <img src="/assets/images/2026-08/audit-events-circled-around-important-values.png" alt="Intune audit log activity details pane showing a Patch of a DeviceManagementConfigurationPolicyAssignment on the FilterDemo Windows Security Baseline policy, performed by the Microsoft Intune portal extension." />
  </div>
  <button class="gallery-nav gallery-prev" aria-label="Previous screenshot">&#8249;</button>
  <button class="gallery-nav gallery-next" aria-label="Next screenshot">&#8250;</button>
  <div class="gallery-dots">
    <button class="gallery-dot active" data-idx="0" aria-label="Screenshot 1"></button>
    <button class="gallery-dot" data-idx="1" aria-label="Screenshot 2"></button>
    <button class="gallery-dot" data-idx="2" aria-label="Screenshot 3"></button>
    <button class="gallery-dot" data-idx="3" aria-label="Screenshot 4"></button>
    <button class="gallery-dot" data-idx="4" aria-label="Screenshot 5"></button>
    <button class="gallery-dot" data-idx="5" aria-label="Screenshot 6"></button>
  </div>
</div>

<ol>
  <li>Both filters in the tenant list, with their Platform column.</li>
  <li>Four cross-platform assignments, all accepted, all still there after a read-back.</li>
  <li>Platform reads Windows. One row carries a Windows filter, the other a macOS filter. Same table, same presentation, both Active.</li>
  <li>The picker lists only Windows filters, while the Selected filter box below it holds a macOS filter, offering only Remove.</li>
  <li>Saving a Windows policy whose assignment carries a macOS filter. The portal is satisfied.</li>
  <li>The audit event for that save. Every old and new value is identical, and the write is attributed to the Microsoft Intune portal extension.</li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>The lesson is not really about filters. The admin center is a shell around an API, and a fair amount of what looks like a rule of Intune turns out to be a rule of that shell. Pull the two apart and the safeguards do not come with you: the API underneath accepts what the portal would have refused, and says nothing about it. So read “the UI does not offer it” as “nobody checked this below the UI”. No automation inherits those safeguards for you, which makes them yours to build.</p>

<p>Run the script against your own tenant. Not because you certainly have one of these, but because you cannot see it if you do.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters">Create assignment filters in Microsoft Intune</a></li>
  <li><a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters-device-properties">Assignment filter properties and operators reference</a></li>
  <li><a href="https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/filters-supported-workloads">Platforms and policy types supported by assignment filters</a></li>
  <li><a href="https://learn.microsoft.com/en-us/graph/api/intune-policyset-deviceandappmanagementassignmentfilter-getplatformsupportedproperties">getPlatformSupportedProperties function - Microsoft Graph beta</a></li>
  <li><a href="https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfigv2-devicemanagementconfigurationpolicyassignment?view=graph-rest-beta">deviceManagementConfigurationPolicyAssignment resource type - Microsoft Graph beta</a></li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="Graph API" />
      
        <category term="PowerShell" />
      
        <category term="Security" />
      
        <category term="Troubleshooting" />
      

      
      
        <summary type="html"><![CDATA[Intune enforces the platform on assignment filters in the admin center but not in Microsoft Graph, and the portal writes the mismatch back for you.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">Bulk Intune Reporting with the Graph Export API</title>
      <link href="https://rksolutions.nl/posts/bulk-intune-reporting-graph-export-api/" rel="alternate" type="text/html" title="Bulk Intune Reporting with the Graph Export API" />
      <published>2026-08-04T00:00:00+00:00</published>
      <updated>2026-08-04T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/bulk-intune-reporting-graph-export-api</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/bulk-intune-reporting-graph-export-api/"><![CDATA[<p>Pulling reporting data out of Microsoft Intune at scale has always been a bit of a headache. The good news is that there is an API built into Microsoft Graph that takes most of that pain away: the Intune <strong>Export API</strong>, exposed through the <code class="language-plaintext highlighter-rouge">exportJobs</code> endpoint. Instead of piecing a report together call by call, you ask Intune to build the whole thing and hand it back as a single file. Here is a quick tour of what it is, the problem it solves, and what it replaces.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#the-problem-it-solves">The problem it solves</a></li>
  <li><a href="#what-the-export-api-does">What the Export API does</a></li>
  <li><a href="#what-it-can-replace">What it can replace</a></li>
  <li><a href="#how-it-works">How it works</a></li>
</ul>

<h2 id="the-problem-it-solves">The problem it solves</h2>

<p>The operational Graph endpoints are great at answering one small question at a time: the state of a device, the settings on a policy. The trouble starts when you want the whole picture across the tenant. You end up nesting loops - devices, then their policies, then their settings - and the number of calls multiplies until you are firing off tens of thousands of requests per run. You spend more time fighting throttling and retries than actually using the data. And the frustrating part is that it is not the amount of data that is slow, it is the sheer number of round trips.</p>

<h2 id="what-the-export-api-does">What the Export API does</h2>

<p>The Export API flips the model. Instead of you pulling raw pieces and stitching them together, you ask Intune to build the finished report on its side and hand you the file. It is the programmatic version of the <strong>Export</strong> button in the admin center: the same reports, generated server-side and delivered as a single file, a zipped CSV or JSON. One request kicks off the job, and once Intune has built it you download that one file, whatever the size of the tenant.</p>

<figure class="img-widget my-6 rounded-xl overflow-hidden border border-[--bd-strong] bg-[--surface] group relative" style="margin-left:auto;margin-right:auto;width:min(100%,1322px)">
  <div class="overflow-hidden">
    <img src="/assets/images/2026-07/intune-reports-overview.png" alt="Microsoft Intune admin center Reports overview page, showing Device management, Endpoint security, Analytics and other report categories in the left navigation." width="1322" height="1144" loading="lazy" class="transition-transform duration-300 group-hover:scale-[1.02]" style="display:block;width:100%;height:auto;cursor:zoom-in" onclick="openLightbox(['/assets/images/2026-07/intune-reports-overview.png'],0)" />
  </div>
  
  <figcaption class="px-4 py-2.5 text-[11px] text-[--tx-med] border-t border-[--bd] leading-relaxed">
    The Reports hub in the Intune admin center. The Export API pulls these same reports programmatically, no clicking required.
  </figcaption>
  
</figure>

<h2 id="what-it-can-replace">What it can replace</h2>

<p>Anywhere you are looping Graph to build a bulk report, the Export API can replace the whole loop. Compliance per setting, app install status, device inventory, Defender health, Windows update rollout - if you can see it in the admin center, there is almost certainly a report name for it. Microsoft’s own example took a compliance export from roughly 100,000 calls and 2.5 hours down to about 15 calls and 15 minutes (<a href="https://techcommunity.microsoft.com/blog/intunecustomersuccess/from-hours-to-minutes-rethinking-microsoft-intune-compliance-reporting-with-the-/4540554">source</a>). In a small test tenant of my own, the same compliance report came back in three Graph calls and about eleven seconds. Same data, a fraction of the effort, and it barely grows as the tenant does.</p>

<p>The best part is that it is one endpoint for the <a href="https://learn.microsoft.com/en-gb/intune/device-management/reports/ref-graph-available-reports">entire Intune reporting catalog</a>. Change the report name and you are exporting something else, using the exact same flow.</p>

<h2 id="how-it-works">How it works</h2>

<p>Under the hood it is three simple steps: ask for a report, wait for it to finish, download the result. In PowerShell that is only a handful of lines with the <code class="language-plaintext highlighter-rouge">Microsoft.Graph.Authentication</code> module:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Install the only module we need if it is not already there
if (-not (Get-Module Microsoft.Graph.Authentication -ListAvailable)) {
    Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
}

Connect-MgGraph -Scopes 'DeviceManagementConfiguration.Read.All'
$uri = 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs'

# 1. Ask Intune to build the report
$job = Invoke-MgGraphRequest -Method POST -Uri $uri -Body @{
    reportName = 'DeviceStatusSummaryByCompliancePolicySettingsReportV3'
    format     = 'csv'
}

# 2. Wait for the job to finish
do {
    Start-Sleep -Seconds 5
    $job = Invoke-MgGraphRequest -Method GET -Uri "$uri('$($job.id)')"
} until ($job.status -eq 'completed')

# 3. Download the finished file (a zipped CSV)
Invoke-WebRequest -Uri $job.url -OutFile 'report.zip'
</code></pre></div></div>

<p>That is the whole idea. Kick off a job, poll until it is done, download one file. So the next time you catch yourself writing a loop over every device in the tenant, stop and check whether a report already exists. There is a good chance it does, and the Export API will hand it to you in a single shot. And if you like turning raw Intune data into something you can act on, take a look at my <a href="/posts/intune-anomalies-report-script/">Intune anomalies report</a> next.</p>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="PowerShell" />
      
        <category term="Graph API" />
      
        <category term="Reports" />
      
        <category term="Compliance" />
      
        <category term="Automation" />
      

      
      
        <summary type="html"><![CDATA[What the Intune Graph Export API (exportJobs) is, the problem it solves, and the slow per-device Graph loops it replaces when you need bulk reporting at scale.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">Microsoft Entra Passkey Dynamic Migration: The Setting You Won’t Find in the Portal</title>
      <link href="https://rksolutions.nl/posts/microsoft-entra-passkey-dynamic-migration/" rel="alternate" type="text/html" title="Microsoft Entra Passkey Dynamic Migration: The Setting You Won’t Find in the Portal" />
      <published>2026-07-30T00:00:00+00:00</published>
      <updated>2026-07-30T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/microsoft-entra-passkey-dynamic-migration</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/microsoft-entra-passkey-dynamic-migration/"><![CDATA[<p>Microsoft-provided SMS and voice authentication are retiring from Microsoft Entra ID on <strong>February 1, 2027</strong>. Before that, on <strong>September 1, 2026</strong>, Microsoft starts enabling eligible SMS and voice users for passkeys and brings them into a Microsoft-managed passkey registration campaign.</p>

<p>That is the part administrators need to notice. You can review the visible <strong>Registration campaign</strong> and <strong>System-preferred authentication</strong> settings in the Entra admin center and still miss the control over Microsoft’s automatic rollout. The temporary opt-out is a Microsoft Graph property named <code class="language-plaintext highlighter-rouge">optOutSettings.passkeyDynamicMigration</code>.</p>

<p>This is not an article about a new switch to turn on. It is about avoiding two bad surprises: users receiving passkey prompts before your support and communication plan is ready, or an organisation assuming that an opt-out keeps Microsoft-provided SMS working after February 2027.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#start-with-the-change-microsoft-is-making">Start with the change Microsoft is making</a></li>
  <li><a href="#the-hidden-control-passkey-dynamic-migration">The hidden control: passkey dynamic migration</a></li>
  <li><a href="#the-portal-controls-registration-and-sign-in-preference">The portal controls: registration and sign-in preference</a></li>
  <li><a href="#how-the-settings-work-together">How the settings work together</a></li>
  <li><a href="#what-should-my-tenant-do">What should my tenant do?</a></li>
  <li><a href="#four-checks-before-you-change-anything">Four checks before you change anything</a></li>
</ul>

<h2 id="start-with-the-change-microsoft-is-making">Start with the change Microsoft is making</h2>

<p>The timeline explains why the Graph property matters.</p>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>What Microsoft changes</th>
      <th>What an administrator should do</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>September 1, 2026</td>
      <td>Microsoft moves the Registration campaign to <strong>Microsoft managed</strong> state and targets passkeys for users still enabled for SMS or voice. It also auto-enables those users for passkeys in the Authentication Methods Policy.</td>
      <td>Identify those users, prepare their registration route, and communicate the change.</td>
    </tr>
    <tr>
      <td>February 1, 2027</td>
      <td>Microsoft-provided SMS and voice are retired. If no customer-managed telecom provider is configured, a user whose only available MFA method is SMS or voice must register a passkey before they can continue signing in.</td>
      <td>Move users to a phishing-resistant method. If you need a Registration campaign to target Authenticator rather than passkeys, set its state to <strong>Enabled</strong> and choose Microsoft Authenticator.</td>
    </tr>
  </tbody>
</table>

<p>The September action does <strong>not</strong> silently create a passkey. It changes policy and campaign scope. At a later MFA sign-in, the user is nudged to register the credential themselves. The February prompt is different: it cannot be skipped.</p>

<h2 id="the-hidden-control-passkey-dynamic-migration">The hidden control: passkey dynamic migration</h2>

<p>Microsoft publishes the temporary opt-out for the September rollout through Microsoft Graph. Put plainly: <strong>this control is not a portal setting alongside Registration campaign or System-preferred authentication.</strong> It is a tenant-wide property on the authentication methods policy.</p>

<p>Do not treat it as a third three-state policy. Its useful values are simpler:</p>

<table>
  <thead>
    <tr>
      <th>Tenant position</th>
      <th>What happens</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Not temporarily opted out</td>
      <td>Microsoft can apply the September automatic passkey enablement and Microsoft-managed registration-campaign rollout to eligible SMS/voice users.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passkeyDynamicMigration</code> set to <code class="language-plaintext highlighter-rouge">true</code></td>
      <td>The tenant is excluded from that automatic rollout from September 1, 2026 until February 1, 2027, while it completes transition work.</td>
    </tr>
    <tr>
      <td>February 1, 2027 and after</td>
      <td>The SMS and voice retirement applies regardless of this property. It is not an opt-out from the deadline.</td>
    </tr>
  </tbody>
</table>

<p>Use the property when you need to control the timing of Microsoft’s automatic passkey rollout while you move SMS and voice users to the authentication method you selected. That might be passkeys, a different registration-campaign target such as Microsoft Authenticator, or a customer-managed telecom provider for a defined population.</p>

<h3 id="check-the-current-value">Check the current value</h3>

<p>It requires <code class="language-plaintext highlighter-rouge">Policy.Read.AuthenticationMethod</code> and either the <strong>Global Reader</strong> or <strong>Authentication Policy Administrator</strong> role.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Connect-MgGraph</span><span class="w"> </span><span class="nt">-Scopes</span><span class="w"> </span><span class="s1">'Policy.Read.AuthenticationMethod'</span><span class="w"> </span><span class="nt">-NoWelcome</span><span class="w">

</span><span class="nv">$policy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Uri</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy'</span><span class="w">

</span><span class="nv">$policy</span><span class="o">.</span><span class="nf">optOutSettings</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-List</span><span class="w">
</span></code></pre></div></div>

<p>An absent property or a value other than <code class="language-plaintext highlighter-rouge">true</code> means the tenant has not used this temporary opt-out. It does not say that SMS and voice are safe after February 2027.</p>

<h3 id="apply-the-temporary-opt-out">Apply the temporary opt-out</h3>

<p>If you want to temporarily opt out, use <code class="language-plaintext highlighter-rouge">Policy.ReadWrite.AuthenticationMethod</code>. <strong>Authentication Policy Administrator</strong> is the least-privileged built-in role.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Connect-MgGraph</span><span class="w"> </span><span class="nt">-Scopes</span><span class="w"> </span><span class="s1">'Policy.ReadWrite.AuthenticationMethod'</span><span class="w"> </span><span class="nt">-NoWelcome</span><span class="w">

</span><span class="nv">$body</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">optOutSettings</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">passkeyDynamicMigration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$true</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">4</span><span class="w">

</span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">PATCH</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Uri</span><span class="w"> </span><span class="s1">'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy'</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-ContentType</span><span class="w"> </span><span class="s1">'application/json'</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Body</span><span class="w"> </span><span class="nv">$body</span><span class="w">
</span></code></pre></div></div>

<p>The endpoint is under Graph <code class="language-plaintext highlighter-rouge">/beta</code>. Recheck <a href="https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement">Microsoft’s retirement guidance</a> before making a production change, and record the approval, response, and exit plan.</p>

<h2 id="the-portal-controls-registration-and-sign-in-preference">The portal controls: registration and sign-in preference</h2>

<p>The Graph setting decides whether Microsoft temporarily performs its automatic rollout. The following portal settings have different jobs and their own states.</p>

<h3 id="registration-campaign-ask-users-to-register">Registration campaign: ask users to register</h3>

<p>A registration campaign is an adoption tool. After a targeted user completes MFA, Entra can prompt them to register a passkey or Microsoft Authenticator. For a passkey campaign, passkeys (FIDO2) and self-service setup must already be enabled.</p>

<table>
  <thead>
    <tr>
      <th>State</th>
      <th>What it means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Disabled</td>
      <td>No registration campaign prompt.</td>
    </tr>
    <tr>
      <td>Enabled</td>
      <td>The administrator chooses the target method, targets, snooze period, and whether registration is required after the allowed snoozes.</td>
    </tr>
    <tr>
      <td>Microsoft managed</td>
      <td>Microsoft chooses the campaign values. The target can move from Authenticator to passkeys. Microsoft also controls the snooze and enforcement settings. Administrators can still manage inclusion and exclusion.</td>
    </tr>
  </tbody>
</table>

<p>A campaign asks a user to register. It does not create a passkey, make it their default sign-in choice, or remove SMS and voice. <a href="https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-registration-campaign">Microsoft’s registration-campaign guidance</a> has the exact prerequisites and nudge behaviour.</p>

<h3 id="system-preferred-authentication-offer-a-method-that-exists">System-preferred authentication: offer a method that exists</h3>

<p>System-preferred authentication is a sign-in selection rule. If a user has a password, Authenticator, and a passkey, Entra can offer the passkey first. If they have not registered a passkey, there is nothing to offer.</p>

<table>
  <thead>
    <tr>
      <th>State</th>
      <th>What it means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Disabled</td>
      <td>Entra’s normal sign-in selection logic continues.</td>
    </tr>
    <tr>
      <td>Enabled</td>
      <td>Entra applies system-preferred selection at second factor only.</td>
    </tr>
    <tr>
      <td>Microsoft managed</td>
      <td>Entra applies it at first and second factor as Microsoft’s rollout reaches the tenant.</td>
    </tr>
  </tbody>
</table>

<p>Users can choose <strong>Sign in another way</strong>. Conditional Access and authentication strengths still take precedence. <a href="https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-authentication">Microsoft documents the current ordering and limitations</a>.</p>

<h2 id="how-the-settings-work-together">How the settings work together</h2>

<p>These are the three user paths that matter:</p>

<ol>
  <li>
    <p><strong>A deliberate rollout:</strong> You set Registration campaign to <strong>Enabled</strong> for a pilot group. The user completes MFA, sees the passkey prompt, and registers a passkey. With System-preferred authentication enabled, that passkey can be offered first at a later sign-in.</p>
  </li>
  <li>
    <p><strong>Microsoft-managed migration:</strong> The user is still enabled for SMS or voice on September 1, 2026 and the tenant has not opted out. Entra enables passkeys for the user and brings them into a Microsoft-managed passkey campaign. At their next qualifying MFA sign-in, they see the nudge and choose whether to complete registration. System-preferred authentication can only offer the passkey after it exists.</p>
  </li>
  <li>
    <p><strong>Temporary opt-out:</strong> <code class="language-plaintext highlighter-rouge">passkeyDynamicMigration</code> is <code class="language-plaintext highlighter-rouge">true</code>. Microsoft does not perform the automatic September policy and campaign rollout for the tenant during the opt-out period. Your own campaign and system-preferred settings remain separate decisions. The February retirement date does not move.</p>
  </li>
</ol>

<p><strong>Dynamic migration changes policy and campaign scope</strong> → <strong>the campaign prompts registration</strong> → <strong>system-preferred authentication changes a later sign-in experience</strong></p>

<h2 id="what-should-my-tenant-do">What should my tenant do?</h2>

<table>
  <thead>
    <tr>
      <th>Situation</th>
      <th>Recommended action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>You want users to move to passkeys before February 2027.</td>
      <td>Leave dynamic migration available, use a passkey campaign to drive adoption, and use system-preferred authentication to offer registered passkeys first.</td>
    </tr>
    <tr>
      <td>You want the campaign to register Microsoft Authenticator instead of passkeys.</td>
      <td>Set Registration campaign to <strong>Enabled</strong> and target Microsoft Authenticator. This changes the campaign experience. It does not change the SMS and voice retirement date.</td>
    </tr>
    <tr>
      <td>You need time to move a defined SMS/voice population to another method or to configure a customer-managed provider.</td>
      <td>Temporarily opt out only if the automatic September rollout would conflict with that transition work.</td>
    </tr>
    <tr>
      <td>You have not decided how SMS/voice users will move off those methods.</td>
      <td>Start with the SMS/voice user inventory. Do not treat the opt-out as the plan itself.</td>
    </tr>
  </tbody>
</table>

<h2 id="four-checks-before-you-change-anything">Four checks before you change anything</h2>

<ul>
  <li>
    <p><strong>“Our users already have Authenticator, so the September rollout does not apply.”</strong> Not necessarily. Microsoft scopes the automatic rollout to users who are enabled for SMS or voice in the Authentication Methods Policy or legacy MFA settings. Authenticator registration alone does not remove that scope.</p>
  </li>
  <li>
    <p><strong>“System-preferred authentication will create the passkey.”</strong> No. It offers the strongest suitable method the user has already registered. Use a Registration campaign when you need users to create a new method.</p>
  </li>
  <li>
    <p><strong>“The Registration campaign migrates users.”</strong> It asks them to register after MFA. The user still completes passkey registration, or postpones the prompt where the campaign allows it.</p>
  </li>
  <li>
    <p><strong>“Opting out means SMS will still work after February 2027.”</strong> It only delays Microsoft’s September automatic passkey enablement and campaign rollout. It does not extend Microsoft-provided SMS or voice.</p>
  </li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement">Passkeys by default and retirement of Microsoft-provided SMS and voice authentication</a></li>
  <li><a href="https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-registration-campaign">Run a registration campaign to set up a passkey or Microsoft Authenticator</a></li>
  <li><a href="https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-authentication">System-preferred authentication in Microsoft Entra ID</a></li>
  <li><a href="https://learn.microsoft.com/en-us/graph/api/authenticationmethodspolicy-get?view=graph-rest-beta">Get authenticationMethodsPolicy - Microsoft Graph beta</a></li>
  <li><a href="https://learn.microsoft.com/en-us/graph/api/authenticationmethodspolicy-update?view=graph-rest-beta">Update authenticationMethodsPolicy - Microsoft Graph beta</a></li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Entra ID" />
      
        <category term="Authentication" />
      
        <category term="Passkeys" />
      
        <category term="MFA" />
      
        <category term="Security" />
      

      
      
        <summary type="html"><![CDATA[Passkey dynamic migration is a Graph-only temporary opt-out for Microsoft's SMS and voice transition. Learn how it differs from registration campaigns and system-preferred authentication.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">Language Pack Deployer: Deploy Language Settings with Intune</title>
      <link href="https://rksolutions.nl/posts/language-pack-deployer-publish-language-settings-to-intune/" rel="alternate" type="text/html" title="Language Pack Deployer: Deploy Language Settings with Intune" />
      <published>2026-07-27T00:00:00+00:00</published>
      <updated>2026-07-27T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/language-pack-deployer-publish-language-settings-to-intune</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/language-pack-deployer-publish-language-settings-to-intune/"><![CDATA[<p>An international language rollout is never just a display-language setting. One keyboard produces <code class="language-plaintext highlighter-rouge">ë</code> or <code class="language-plaintext highlighter-rouge">é</code>; another produces a quotation mark and a plain <code class="language-plaintext highlighter-rouge">e</code>. Add date format, decimal separator, regional format, and time zone, and every office can need a different combination. A wiped, returned laptop can also come back in its original OEM language.</p>

<p>Two years ago, I solved that with a PowerShell Win32 app. It worked, but every new country meant working out the values, detection, packaging, and targeting again. <strong><a href="https://royklo.github.io/LanguagePackDeployer/">Language Pack Deployer</a></strong> lifts that solution into a web app: define the state, choose the Intune method, set targeting, and download or publish the deployment.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#the-problem-it-solves">The problem it solves</a></li>
  <li><a href="#three-windows-deployment-methods">Three Windows deployment methods</a></li>
  <li><a href="#win32-app-enrollment-and-rollback">Win32 app: enrollment and rollback</a></li>
  <li><a href="#platform-script-direct-configuration">Platform Script: direct configuration</a></li>
  <li><a href="#remediations-ongoing-state">Remediations: ongoing state</a></li>
  <li><a href="#device-state-user-state-and-targeting">Device state, user state, and targeting</a></li>
  <li><a href="#from-browser-to-intune">From browser to Intune</a></li>
  <li><a href="#a-practical-rollout">A practical rollout</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
  <li><a href="#related">Related</a></li>
</ul>

<h2 id="the-problem-it-solves">The problem it solves</h2>

<p>Language configuration includes the pack, keyboard behaviour, date and time format, decimal separator, region, system locale, and time zone. Two users can share a display language while needing different keyboard behaviour for accented characters.</p>

<p>The manual answer becomes a folder of almost-identical scripts. Language Pack Deployer replaces that repetition with 100 locale presets and defaults you can override.</p>

<h2 id="three-windows-deployment-methods">Three Windows deployment methods</h2>

<p>Windows gives you three valid delivery models. They are not interchangeable: a successful script run, a successful installation, and a device that remains in the intended state are different outcomes.</p>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Use it when</th>
      <th>What it gives you</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Win32 app</strong></td>
      <td>Language belongs in enrollment or an application lifecycle</td>
      <td>Detection, installation, and rollback</td>
    </tr>
    <tr>
      <td><strong>Platform Script</strong></td>
      <td>Remediations are not available in your tenant</td>
      <td>A direct one-time configuration</td>
    </tr>
    <tr>
      <td><strong>Remediations</strong></td>
      <td>You need Intune to enforce the intended state</td>
      <td>One-time or scheduled detection and correction</td>
    </tr>
  </tbody>
</table>

<p>The same workflow can also create a macOS shell script.</p>

<h2 id="win32-app-enrollment-and-rollback">Win32 app: enrollment and rollback</h2>

<p>Choose a <strong>Win32 app</strong> when language configuration belongs in Autopilot enrollment or a required-app lifecycle. Before changing the device, it records the original language state with a deterministic configuration ID. On uninstall, the generated script restores that state. Language packs can remain on disk, but the device returns to its previous configuration.</p>

<p>Detection can also adopt a device that is already correct, rather than reinstalling language packs. The <code class="language-plaintext highlighter-rouge">.intunewin</code> package is built in the browser, while its final upload runs from the generated <code class="language-plaintext highlighter-rouge">Deploy-Win32App.ps1</code> on your own device. A browser-only app cannot upload Win32 content to Intune without a relay service.</p>

<h2 id="platform-script-direct-configuration">Platform Script: direct configuration</h2>

<p>Choose a <strong>Platform Script</strong> when you need to apply a configuration directly but cannot use Remediations in the tenant. It is the practical option for a one-time change when scheduled detection and correction are not available.</p>

<p>A successful run proves that Intune ran the script, not that a user or policy did not later change the state. Use Remediations when you must prove and correct the state over time.</p>

<h2 id="remediations-ongoing-state">Remediations: ongoing state</h2>

<p>Choose <strong>Remediations</strong> when Intune needs to enforce the intended language state. Its separate, read-only detection script evaluates installed languages, system UI language, system locale, and time zone. If the actual state does not match the configuration, the remediation script corrects it.</p>

<p>You can run the policy <strong>once</strong> for a controlled correction, or schedule it <strong>hourly</strong> or <strong>daily</strong> when the device should be checked for drift over time.</p>

<p>Unlike a Win32 receipt, this checks the current state, not only whether an installer completed.</p>

<p>Before publishing a Remediations policy, the flow checks <strong>Windows license verification</strong>. Intune can accept a policy that will never run if verification is off, so Language Pack Deployer requires that setting to be enabled before the deployment continues. Microsoft documents the wider <a href="https://learn.microsoft.com/en-us/intune/device-management/tools/deploy-remediations">Remediations licensing and scheduling model</a>.</p>

<h2 id="device-state-user-state-and-targeting">Device state, user state, and targeting</h2>

<p>For Platform Scripts and Remediations, choose device scope, user scope, or both:</p>

<ul>
  <li><strong>Device-wide and new-user defaults</strong> run as <code class="language-plaintext highlighter-rouge">SYSTEM</code> and prepare language packs, system settings, the welcome screen, and future profiles.</li>
  <li><strong>Existing logged-on users</strong> update the active profile’s language list, keyboard, culture, display-language override, and home region.</li>
</ul>

<p>Selecting both creates separate policies and makes the user policy wait for device preparation. You can create one deployment per language with its own assignment, or bundle several languages and choose the primary display language. Every artifact supports its own scope tags, groups, assignment filters, and Remediations schedule.</p>

<h2 id="from-browser-to-intune">From browser to Intune</h2>

<p>When you sign in, Language Pack Deployer uses <strong>delegated</strong> Microsoft Graph permissions directly against your own tenant. It acts as your Entra ID account, so every action respects the Intune RBAC roles and Entra permissions that account already has. It does not use a client secret or an app-only identity with its own standing access.</p>

<p>The app reads groups, scope tags, and assignment filters, then creates and assigns the Intune artifact through these Graph routes:</p>

<table>
  <thead>
    <tr>
      <th>Intune artifact</th>
      <th>Microsoft Graph route</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Windows Platform Script</td>
      <td><code class="language-plaintext highlighter-rouge">/deviceManagement/deviceManagementScripts</code></td>
    </tr>
    <tr>
      <td>Remediations</td>
      <td><code class="language-plaintext highlighter-rouge">/deviceManagement/deviceHealthScripts</code></td>
    </tr>
    <tr>
      <td>macOS shell script</td>
      <td><code class="language-plaintext highlighter-rouge">/deviceManagement/deviceShellScripts</code></td>
    </tr>
    <tr>
      <td>Assignment</td>
      <td>The matching <code class="language-plaintext highlighter-rouge">/{id}/assign</code> route</td>
    </tr>
  </tbody>
</table>

<p>The requested delegated permissions are scoped to that workflow:</p>

<table>
  <thead>
    <tr>
      <th>Permission</th>
      <th>Used for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeviceManagementApps.ReadWrite.All</code></td>
      <td>Win32 app creation and upload</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeviceManagementScripts.ReadWrite.All</code></td>
      <td>Platform Scripts and Remediations</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeviceManagementConfiguration.ReadWrite.All</code></td>
      <td>Intune configuration and assignments</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeviceManagementServiceConfig.ReadWrite.All</code></td>
      <td>Windows license-verification check</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeviceManagementRBAC.Read.All</code></td>
      <td>Scope tags</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Group.Read.All</code></td>
      <td>Group picker and assignments</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">User.Read</code></td>
      <td>Signed-in user display</td>
    </tr>
  </tbody>
</table>

<p>You can build and review every artifact without signing in. If you prefer not to grant a browser access to the tenant, download the files instead: scripts are generated in the correct format for manual Intune upload, Remediations are exported as <code class="language-plaintext highlighter-rouge">Detect-LanguagePack.ps1</code> and <code class="language-plaintext highlighter-rouge">Remediate-LanguagePack.ps1</code>, and the Win32 bundle includes the <code class="language-plaintext highlighter-rouge">.intunewin</code>, deployment script, and README. The Win32 route keeps the same privacy boundary even when publishing, because the final upload runs from your own PowerShell session.</p>

<h2 id="a-practical-rollout">A practical rollout</h2>

<ol>
  <li>Select the operating system and required locales, then confirm keyboard behaviour, region, and time zone.</li>
  <li>Choose separate output for per-language targeting, or a bundle for shared devices.</li>
  <li>Select Win32 for enrollment and rollback, Platform Script for a direct action, or Remediations for enforcement.</li>
  <li>Choose device state, existing-user state, or both.</li>
  <li>Review the output, set scope tags and pilot assignments, then validate a user session after a sign-out or restart.</li>
</ol>

<p>For a returned device that has been wiped, this replaces the hunt for an old country-specific script. Reapply the intended configuration and target it the same way you would any other managed state.</p>

<h2 id="conclusion">Conclusion</h2>

<p><strong>Language Pack Deployer</strong> turns an error-prone international configuration task into a reviewed Intune deployment. Build the language state once, choose the method that matches the lifecycle, and publish it with the targeting already in place.</p>

<p>Open <a href="https://royklo.github.io/LanguagePackDeployer/">Language Pack Deployer</a> and download an artifact before you connect a tenant.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/macpppc-deploy-macos-pppc-profiles-to-intune/">MacPPPC: Build and Deploy macOS PPPC Profiles to Intune from Your Browser</a> - another browser-based Intune tool that creates and targets policies directly from the browser.</li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="Graph API" />
      
        <category term="Automation" />
      
        <category term="Tools" />
      
        <category term="PowerShell" />
      

      
      
        <summary type="html"><![CDATA[Deploy Windows and macOS language settings with Intune without rebuilding scripts for every country, device state, or deployment method.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">OneDrive Open at Login on macOS: Why It Stopped Working</title>
      <link href="https://rksolutions.nl/posts/macos-onedrive-open-at-login/" rel="alternate" type="text/html" title="OneDrive Open at Login on macOS: Why It Stopped Working" />
      <published>2026-07-15T00:00:00+00:00</published>
      <updated>2026-07-15T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/macos-onedrive-open-at-login</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/macos-onedrive-open-at-login/"><![CDATA[<p>A user on a freshly enrolled Mac reports that their files are not syncing. You check the device: OneDrive is installed, the configuration profile landed, and the <strong>Open at login</strong> setting is sitting right there in your Settings Catalog policy, set to <strong>True</strong>. You reboot the Mac to be sure. OneDrive does not open at login until the user launches it by hand.</p>

<p>The profile is fine. The setting applied. It just does not do anything anymore. OneDrive’s <code class="language-plaintext highlighter-rouge">OpenAtLogin</code> managed preference has been deprecated since sync app <strong>24.113</strong> and is a no-op on current builds - yet macOS baselines everywhere still set it and assume OneDrive launches at login. Even Microsoft’s own <a href="https://learn.microsoft.com/intune/solutions/end-to-end-guides/macos-endpoints-get-started">end-to-end guide for macOS endpoints</a> still tells you to set <strong>Open at login: True</strong>. And the setting that looks like the obvious replacement - a Managed Login Items profile - can’t do the job either, for a reason worth understanding on its own.</p>

<p>This post is a snapshot of behavior I verified on-device in July 2026, on OneDrive sync app 26.113 and macOS 26.x. Re-check the referenced pages against your own builds before rolling anything out.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#the-setting-every-baseline-still-ships">The setting every baseline still ships</a></li>
  <li><a href="#managed-allowed-and-still-disabled">Managed, allowed, and still disabled</a></li>
  <li><a href="#allow-is-not-enable-the-btm-mental-model">Allow is not enable: the BTM mental model</a></li>
  <li><a href="#the-fix-createloginitem-as-the-signed-in-user">The fix: /createloginitem, as the signed-in user</a></li>
  <li><a href="#deploying-it-a-ready-made-intune-script">Deploying it: a ready-made Intune script</a></li>
  <li><a href="#verifying-on-a-device">Verifying on a device</a></li>
  <li><a href="#what-this-does-not-fix">What this does not fix</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
  <li><a href="#related">Related</a></li>
  <li><a href="#references">References</a></li>
</ul>

<h2 id="the-setting-every-baseline-still-ships">The setting every baseline still ships</h2>

<p>For years, making OneDrive start automatically on a managed Mac was one managed preference - <strong>Microsoft Office</strong> &gt; <strong>Microsoft OneDrive</strong> &gt; <strong>Open at login</strong> in Intune’s Settings Catalog:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;key&gt;</span>OpenAtLogin<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;true/&gt;</span>
</code></pre></div></div>

<p>Microsoft’s <a href="https://learn.microsoft.com/sharepoint/deploy-and-configure-on-macos#openatlogin">OneDrive for Mac deployment documentation</a> now carries this note on the setting:</p>

<blockquote>
  <p><em>“The OpenAtLogin setting will be deprecated with Sync app 24.113. Please refer to Background services to configure the appropriate profile for enabling OneDrive to start automatically when the user logs in.”</em></p>
</blockquote>

<p>The background is macOS 13 (Ventura): Apple moved login items and background services under <strong>Background Task Management</strong> (BTM), the framework behind the “Login Items &amp; Extensions” pane in System Settings. Since then, OneDrive listens to the macOS login item system instead of its own preference. The pref still applies cleanly - <code class="language-plaintext highlighter-rouge">defaults read</code> will happily show you <code class="language-plaintext highlighter-rouge">OpenAtLogin = 1</code> under <code class="language-plaintext highlighter-rouge">/Library/Managed Preferences</code> - it just no longer does anything. Nothing errors, nothing reports non-compliant, the profile shows green in Intune. OneDrive simply is not there after login.</p>

<p>One important scoping note: Macs that have been in use for a while do not show this. Their login item was registered long ago - back when the pref still worked, or the first time the user flipped the toggle - and that registration persists across OneDrive updates and reboots. This is purely a fresh-enrollment problem, which is exactly why it slips through: the longtime devices in your pilot group look fine while every newly provisioned Mac comes up wrong.</p>

<h2 id="managed-allowed-and-still-disabled">Managed, allowed, and still disabled</h2>

<p>The deprecation note points at the “Background services” profile as the replacement, and that is where the second trap sits. That profile is a <strong>Managed Login Items</strong> payload (<code class="language-plaintext highlighter-rouge">com.apple.servicemanagement</code>) - typically an allow rule for Microsoft’s team identifier <code class="language-plaintext highlighter-rouge">UBF8T346G9</code> - and on my baseline it was already deployed.</p>

<p>So: managed preference applied, Managed Login Items rule applied, and OneDrive still absent from <strong>System Settings</strong> &gt; <strong>General</strong> &gt; <strong>Login Items &amp; Extensions</strong> after enrollment. Time to ask BTM itself, with its forensics tool <code class="language-plaintext highlighter-rouge">sfltool</code>:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>% sfltool dumpbtm | <span class="nb">grep</span> <span class="nt">-A8</span> <span class="s2">"Name: OneDrive Launcher"</span>
                 Name: OneDrive Launcher
       Developer Name: Microsoft Corporation
      Team Identifier: UBF8T346G9
                 Type: login item <span class="o">(</span>0x4<span class="o">)</span>
          Disposition: <span class="o">[</span>disabled, allowed]
    Bundle Identifier: com.microsoft.OneDriveLauncher
</code></pre></div></div>

<p>(Output trimmed; the exact field layout varies a little between macOS builds. The <code class="language-plaintext highlighter-rouge">Disposition</code> line is what you are after.)</p>

<p>That disposition line is the smoking gun: <code class="language-plaintext highlighter-rouge">[disabled, allowed]</code>. The profile did its job - the item is <em>allowed</em>, the user cannot toggle it off, no nag notification. But it is still <em>disabled</em>, because nothing ever <em>enabled</em> it. Two different flags, owned by two different parties.</p>

<h2 id="allow-is-not-enable-the-btm-mental-model">Allow is not enable: the BTM mental model</h2>

<p>This is the mental model that makes the whole thing click, and it applies to far more than OneDrive:</p>

<p><strong>On macOS Ventura and later, an MDM profile approves login and background items - it never enables them.</strong></p>

<p>Apple’s MDM documentation describes the Managed Login Items payload as making an item “automatically approved”. What the payload does not contain is any mechanism to <em>register</em> a login item the app has not registered itself. Registration goes through Apple’s <code class="language-plaintext highlighter-rouge">SMAppService</code> API, and that call has to come from the app side, in a user context.</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Who controls it</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OpenAtLogin</code> managed preference</td>
      <td>MDM (Settings Catalog)</td>
      <td>Nothing on sync app 24.113+ - deprecated no-op</td>
    </tr>
    <tr>
      <td>Managed Login Items profile (<code class="language-plaintext highlighter-rouge">com.apple.servicemanagement</code>)</td>
      <td>MDM</td>
      <td><em>Allows</em> the item: locks it on, hides the nag. Cannot enable it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SMAppService</code> registration</td>
      <td>The app itself, as the user</td>
      <td>Actually <em>enables</em> the login item</td>
    </tr>
  </tbody>
</table>

<p>Under the old model, OneDrive registered its own launcher because the pref told it to. Under the new model, it registers the login item when the user flips the toggle in OneDrive’s preferences - and on a freshly provisioned Mac, nobody has flipped it. Every layer the MDM admin controls is green, and the one layer that actually starts the app is untouched.</p>

<h2 id="the-fix-createloginitem-as-the-signed-in-user">The fix: /createloginitem, as the signed-in user</h2>

<p>Microsoft closed this gap in OneDrive <strong>26.027</strong> with two <a href="https://learn.microsoft.com/sharepoint/deploy-and-configure-on-macos#manage-login-item-registration-via-command-line">command-line arguments</a> that programmatically register or unregister the login item:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Register OneDrive as a login item</span>
open <span class="nt">-a</span> OneDrive <span class="nt">--args</span> /createloginitem

<span class="c"># Unregister OneDrive as a login item</span>
open <span class="nt">-a</span> OneDrive <span class="nt">--args</span> /removeloginitem
</code></pre></div></div>

<p>These are fire-and-exit commands: OneDrive performs the registration and immediately terminates without launching the full sync client. On my test device, <code class="language-plaintext highlighter-rouge">/createloginitem</code> flipped the BTM disposition from <code class="language-plaintext highlighter-rouge">[disabled, allowed]</code> to <code class="language-plaintext highlighter-rouge">[enabled, allowed]</code>, and it persisted across a reboot.</p>

<p>Two constraints matter for deployment:</p>

<ul>
  <li><strong>OneDrive 26.027 or later, macOS 13 or later.</strong> Older sync builds do not know the argument (and pre-24.113 builds still honor <code class="language-plaintext highlighter-rouge">OpenAtLogin</code> anyway).</li>
  <li><strong>It must run as the signed-in user.</strong> <code class="language-plaintext highlighter-rouge">open -a</code> targets the user’s GUI (Aqua) session. Run it as root or another local account and it fails - on my device, running it as the local admin produced a blunt <code class="language-plaintext highlighter-rouge">Unable to find application named 'OneDrive'</code>, even though the app was right there in <code class="language-plaintext highlighter-rouge">/Applications</code>.</li>
</ul>

<h2 id="deploying-it-a-ready-made-intune-script">Deploying it: a ready-made Intune script</h2>

<p>You could deploy the one-liner as-is, but on real fleets the timing is against you: the script can land before OneDrive does, the installed build can be too old, and a recurring Intune script should not keep poking OneDrive after the job is done. A guarded, idempotent version is published in Microsoft’s <a href="https://github.com/microsoft/intune-my-macs">Intune My Macs</a> repo as <a href="https://github.com/microsoft/intune-my-macs/blob/main/macOS/scripts/intune/scr-app-108-onedrive-openatlogin.zsh"><code class="language-plaintext highlighter-rouge">scr-app-108-onedrive-openatlogin.zsh</code></a>, which:</p>

<ul>
  <li><strong>No-ops until OneDrive is installed</strong> - handles the enrollment race, and means OneDrive does not need to be a required app. The script self-heals whenever OneDrive lands, by any install path.</li>
  <li><strong>No-ops below 26.027</strong> - older builds still honor the legacy pref, so the script stays out of the way rather than launching the full app.</li>
  <li><strong>Runs the enable exactly once</strong> - a per-user marker file makes every subsequent scheduled run a cheap no-op that never re-launches OneDrive. Delete the marker to force a re-run.</li>
</ul>

<p>Deploy it under <strong>Devices</strong> &gt; <strong>macOS</strong> &gt; <strong>Shell scripts</strong> with <strong>Run script as signed-in user = Yes</strong>, execution frequency every 15 minutes, 3 retries. The cadence exists purely to win the enrollment race; once the marker is down, it costs nothing.</p>

<p>Keep your Managed Login Items profile alongside it: the script <em>enables</em> the login item, the profile is what stops the user from disabling it again. You want both halves.</p>

<h2 id="verifying-on-a-device">Verifying on a device</h2>

<p>After the script has run for the signed-in user, check the BTM disposition (works without <code class="language-plaintext highlighter-rouge">sudo</code> for your own session):</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>% sfltool dumpbtm | <span class="nb">grep</span> <span class="nt">-A8</span> <span class="s2">"Name: OneDrive Launcher"</span>
                 Name: OneDrive Launcher
       Developer Name: Microsoft Corporation
      Team Identifier: UBF8T346G9
                 Type: login item <span class="o">(</span>0x4<span class="o">)</span>
          Disposition: <span class="o">[</span>enabled, allowed, notified]
    Bundle Identifier: com.microsoft.OneDriveLauncher
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">[enabled, allowed]</code> is the healthy end state: enabled by the registration command, allowed (and locked) by the profile. OneDrive now shows up in <strong>Login Items &amp; Extensions</strong> with the toggle greyed out, and it opens at login on the next reboot without anyone touching anything. One reading tip: OneDrive also registers developer-group (<code class="language-plaintext highlighter-rouge">0x20</code>) and app (<code class="language-plaintext highlighter-rouge">0x2</code>) entries that can legitimately show <code class="language-plaintext highlighter-rouge">[disabled]</code> - the login item entry, type <code class="language-plaintext highlighter-rouge">0x4</code>, is the one that matters.</p>

<h2 id="what-this-does-not-fix">What this does not fix</h2>

<p>Login-item registration gets OneDrive <em>launched</em> at login. It does not remove the one-time first-run experience: the user still signs in once, and macOS still asks the user to enable OneDrive’s File Provider the first time. That consent is an Apple user-privacy gate - no MDM payload, profile, or script automates it, by design. Budget for it in your onboarding communication instead of hunting for a setting that does not exist.</p>

<h2 id="conclusion">Conclusion</h2>

<p>If your macOS baseline sets OneDrive’s <strong>Open at login</strong> and you are on a current sync build, that setting is doing nothing - and the Managed Login Items profile that the deprecation note points you toward can only <em>allow</em> the login item, never <em>enable</em> it. The only mechanism that actually enables it is OneDrive’s own <code class="language-plaintext highlighter-rouge">/createloginitem</code> command (26.027+), run as the signed-in user - deployed most easily via the ready-made <code class="language-plaintext highlighter-rouge">scr-app-108</code> script in the Intune My Macs repo.</p>

<p>The broader lesson travels beyond OneDrive: on macOS Ventura and later, Background Task Management splits login items into an <em>allow</em> flag the MDM owns and an <em>enable</em> flag only the app (in user context) can set. Any baseline that assumes a profile alone gets an app running at login is making a bet that Apple has already voided. Audit yours with <code class="language-plaintext highlighter-rouge">sfltool dumpbtm</code> - <code class="language-plaintext highlighter-rouge">[disabled, allowed]</code> is the tell.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/macos-laps-intune-compliance-policy/">macOS LAPS vs. Intune Password Compliance Policy: Why It Breaks</a> - another macOS-via-Intune behavior where a policy that looks passive quietly changes device state.</li>
  <li><a href="/posts/macpppc-deploy-macos-pppc-profiles-to-intune/">MacPPPC: Build and Deploy macOS PPPC Profiles to Intune from Your Browser</a> - more macOS payload plumbing where Apple’s MDM rules bite if you get the details wrong.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/sharepoint/deploy-and-configure-on-macos">Microsoft Learn - Deploy and configure the OneDrive sync app for Mac</a> - the <code class="language-plaintext highlighter-rouge">OpenAtLogin</code> deprecation note, the Background services profile, and the <code class="language-plaintext highlighter-rouge">/createloginitem</code> / <code class="language-plaintext highlighter-rouge">/removeloginitem</code> command-line arguments.</li>
  <li><a href="https://github.com/microsoft/intune-my-macs/blob/main/macOS/scripts/intune/scr-app-108-onedrive-openatlogin.zsh">GitHub - Intune My Macs, SCR-APP-108</a> - the guarded, run-once deployment script.</li>
  <li><a href="https://learn.microsoft.com/sharepoint/sync-release-notes">Microsoft Learn - OneDrive Sync Release Notes</a> - the sync app builds that shipped the login-item command line support.</li>
  <li><a href="https://support.apple.com/guide/deployment/manage-login-items-background-tasks-depdca572563/web">Apple Platform Deployment - Manage login items and background tasks</a> - Apple’s documentation of the approve-not-enable behavior (“When an item matches a rule, the item is automatically approved”).</li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="M365" />
      
        <category term="Automation" />
      

      
      
        <summary type="html"><![CDATA[OneDrive's OpenAtLogin managed preference is a no-op on current sync builds, and a Managed Login Items profile can only allow a login item, not enable it. The real fix is a small user-context script.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">macOS LAPS vs. Intune Password Compliance Policy: Why It Breaks</title>
      <link href="https://rksolutions.nl/posts/macos-laps-intune-compliance-policy/" rel="alternate" type="text/html" title="macOS LAPS vs. Intune Password Compliance Policy: Why It Breaks" />
      <published>2026-07-05T00:00:00+00:00</published>
      <updated>2026-07-05T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/macos-laps-intune-compliance-policy</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/macos-laps-intune-compliance-policy/"><![CDATA[<p>A user needs to turn on screen sharing for Microsoft Teams in System Settings (just one of many app permissions that need an admin to approve them), but macOS pops the padlock and demands an administrator. They do not have one, so it lands on your desk. You pull the managed admin (LAPS) password from Intune, take over the device, and paste it in. It bounces. You try again, slower this time, convinced you fat-fingered it. Still nothing. By the third attempt you are no longer sure the managed admin account even exists on this device.</p>

<p>The password is correct. It is the exact value Intune generated and stored. macOS is rejecting it anyway, because something told Open Directory that this account needs to change its password before it can authenticate. Unless you deliberately turned on “Change at next authentication” (in which case this is expected), that “something” on current macOS is almost always a compliance policy carrying password settings.</p>

<p>This post is a snapshot of behavior I have reproduced in test environments running macOS 26.5.x. Apple’s Open Directory enforcement can change between releases, so re-test on your own build before assuming any of this still applies.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#what-macos-laps-actually-does">What macOS LAPS actually does</a></li>
  <li><a href="#how-you-notice-the-problem">How you notice the problem</a></li>
  <li><a href="#why-it-happens-compliance-policies-enforce-they-dont-audit">Why it happens: compliance policies enforce, they don’t audit</a></li>
  <li><a href="#the-catch-even-microsoft-flags">The catch even Microsoft flags</a></li>
  <li><a href="#is-the-admin-account-even-there">Is the admin account even there?</a></li>
  <li><a href="#the-fix-that-works-today">The fix that works today</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
  <li><a href="#related">Related</a></li>
  <li><a href="#references">References</a></li>
</ul>

<h2 id="what-macos-laps-actually-does">What macOS LAPS actually does</h2>

<p>When a Mac enrolls through an Automatic Device Enrollment (ADE) profile that has the local admin account option turned on, Intune provisions a managed administrator with a strong random password, stores it encrypted, and rotates it automatically on the schedule you configure. You retrieve the current password from the Intune admin center under <strong>Devices</strong> &gt; <strong>macOS devices</strong> &gt; the device &gt; <strong>Passwords and keys</strong>.</p>

<p>That managed admin sits on top of <strong>Open Directory</strong> (OD), the macOS subsystem that owns local accounts and their password policy. This matters, because OD does not treat the LAPS admin as special. Any passcode rule that lands on the device applies to every managed account, the LAPS admin included. That single design fact is the root of everything below.</p>

<h2 id="how-you-notice-the-problem">How you notice the problem</h2>

<p>There is no error popup that says “your compliance policy invalidated the LAPS password.” You find out the hard way: an admin authentication prompt rejects the stored password. Installing a package, changing a protected setting, unlocking a system preference, anything that elevates, fails as if you typed the wrong thing.</p>

<p>To confirm it is the account and not your typing, open <strong>Terminal</strong> on the Mac and authenticate directly against Open Directory:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>% dscl <span class="nb">.</span> <span class="nt">-authonly</span> &lt;admin-name&gt; <span class="s1">'&lt;password-from-intune&gt;'</span>
&lt;dscl_cmd&gt; DS Error: <span class="nt">-14161</span> <span class="o">(</span>eDSAuthNewPasswordRequired<span class="o">)</span>
</code></pre></div></div>

<p>A healthy account returns nothing and exits 0. The error name is the tell: <code class="language-plaintext highlighter-rouge">eDSAuthNewPasswordRequired</code> means Open Directory recognizes the credential but will not let the account authenticate until its password is changed. That is what separates this bug from a genuine typo, where you would instead get <code class="language-plaintext highlighter-rouge">eDSAuthFailed (-14090)</code>. The password is correct; the account is flagged.</p>

<h2 id="why-it-happens-compliance-policies-enforce-they-dont-audit">Why it happens: compliance policies enforce, they don’t audit</h2>

<p>Here is the mental model that trips people up. A compliance policy looks like a read-only health check: it inspects the device and reports a verdict back. It is not. When a compliance policy contains password settings, Intune pushes those rules to the device and macOS enforces them. The compliance engine is policy-applied, not policy-read.</p>

<p>Microsoft says this plainly in its macOS LAPS documentation:</p>

<blockquote>
  <p><em>“Password settings configured through compliance policies or device restriction templates enable Change at next authentication by default and can cause sign-in issues for newly created LAPS accounts.”</em></p>

  <p><a href="https://learn.microsoft.com/en-us/intune/device-security/laps/setup-macos">Microsoft Learn - Configure macOS ADE local account configuration with LAPS</a></p>
</blockquote>

<p>So a compliance policy that merely <em>checks</em> for a password also flips on <strong>Change at next authentication</strong>, and OD enforces that flag against the LAPS admin. The freshly rotated password is, from OD’s point of view, a password that must be changed before it can be used.</p>

<p>What makes this so easy to miss is that most admins mirror their password requirements in a Settings Catalog configuration profile as well. The compliance policy and the config profile look consistent, the same length, the same complexity, so nothing looks like it is in conflict. The compliance policy’s hidden side effect never draws attention until the managed admin stops authenticating.</p>

<p>This is the shape that causes it, expressed as the macOS compliance policy resource:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"@odata.type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#microsoft.graph.macOSCompliancePolicy"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"macOS - Passcode Compliance"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passwordRequired"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passwordBlockSimple"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passwordMinimumLength"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passwordRequiredType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alphanumeric"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">password*</code> key here is enforced, not audited. The moment this policy reaches the Mac, OD applies the rules to all managed accounts.</p>

<p>Because the cause lives in Apple’s Open Directory rather than in Intune, this is not strictly an Intune problem. The same symptom has been reported on other MDM platforms too (see the references at the end), which is exactly what you would expect from an Apple-side cause. Switching management platforms does not move the wall.</p>

<h2 id="the-catch-even-microsoft-flags">The catch even Microsoft flags</h2>

<p>You might assume that turning <strong>Change at next authentication</strong> off makes you safe. It is the right thing to do, but Microsoft documents a known issue where it was not enough:</p>

<blockquote>
  <p><em>“There is a known issue that affects devices running macOS versions earlier than macOS 26.4. When a macOS device enrolls via ADE with a configured local admin account and a targeted passcode profile, it prompts for an admin password reset, even if Change at next auth is not enabled or if a value is set for Max Age (days). This does not affect the standard account.”</em></p>

  <p><a href="https://learn.microsoft.com/en-us/intune/device-security/laps/setup-macos">Microsoft Learn - Configure macOS ADE local account configuration with LAPS</a></p>
</blockquote>

<p>Microsoft’s documented workaround and fix are these:</p>

<blockquote>
  <p><em>“As a workaround, manually rotate the admin password after the reset on the device to keep the Intune and device password state in sync. Upgrade your device to macOS 26.4 to resolve this issue.”</em></p>

  <p><a href="https://learn.microsoft.com/en-us/intune/device-security/laps/setup-macos">Microsoft Learn - Configure macOS ADE local account configuration with LAPS</a></p>
</blockquote>

<p>None of this makes compliance password settings wrong to use, the trade-off is just worth being honest about. Rotating the password by hand when you take over a device is a perfectly fine approach, and if it suits your workflow there is nothing wrong with it. Just know who ends up doing it: if your Intune RBAC roles do not let the helpdesk rotate the macOS LAPS password, every reset escalates to whoever holds that permission, which is often you.</p>

<h2 id="is-the-admin-account-even-there">Is the admin account even there?</h2>

<p>Before you blame the bug, rule out a simpler explanation: maybe the account was never created in the first place. ADE enrollment can hiccup - a network drop during Setup Assistant, a profile that lands late - and a half-finished enrollment can leave the device without a managed admin at all. From the prompt, that looks identical to a rejected password, so confirm the account actually exists before going further.</p>

<p>Run these on the Mac:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>% <span class="nb">id</span> &lt;admin-name&gt;
<span class="nv">uid</span><span class="o">=</span>503<span class="o">(</span>&lt;admin-name&gt;<span class="o">)</span> <span class="nv">gid</span><span class="o">=</span>20<span class="o">(</span>staff<span class="o">)</span> <span class="nb">groups</span><span class="o">=</span>20<span class="o">(</span>staff<span class="o">)</span>,80<span class="o">(</span>admin<span class="o">)</span>

% dseditgroup <span class="nt">-o</span> checkmember <span class="nt">-m</span> &lt;admin-name&gt; admin
<span class="nb">yes</span> &lt;admin-name&gt; is a member of admin
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">id</code> returns “no such user”, the account was never delivered, investigate the enrollment, not the password. If the account exists but is not in <code class="language-plaintext highlighter-rouge">admin</code>, the profile bound the record without the group membership. If both look healthy but <code class="language-plaintext highlighter-rouge">dscl . -authonly</code> still fails, you are looking at the enforcement bug.</p>

<p>On the tenant side, the <strong>Passwords and keys</strong> pane shows whether Intune is holding a password for this device and when it last rotated. If the pane is empty or the rotation date is old, Intune is not managing the credential the way you think it is, and that is a device or MDM-channel problem rather than the Open Directory bug.</p>

<table>
  <thead>
    <tr>
      <th>Check</th>
      <th>Healthy result</th>
      <th>What it means if it fails</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">id &lt;admin-name&gt;</code></td>
      <td>shows a uid and the <code class="language-plaintext highlighter-rouge">admin</code> group</td>
      <td>“no such user” - the ADE profile never created the account</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dseditgroup -o checkmember -m &lt;admin-name&gt; admin</code></td>
      <td><code class="language-plaintext highlighter-rouge">yes ... is a member of admin</code></td>
      <td>account exists but is not an admin - profile delivered the record without the group</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dscl . -authonly</code></td>
      <td>exits 0 with no output</td>
      <td>a correct password is rejected with <code class="language-plaintext highlighter-rouge">eDSAuthNewPasswordRequired</code> - the enforcement bug</td>
    </tr>
    <tr>
      <td>Passwords and keys pane (Intune)</td>
      <td>a stored password with a recent rotation date</td>
      <td>empty or stale - Intune is not rotating the credential, look at the device or MDM channel</td>
    </tr>
  </tbody>
</table>

<h2 id="the-fix-that-works-today">The fix that works today</h2>

<p>The fix is to stop letting the compliance policy carry the passcode rules and deliver them declaratively through DDM instead. Two steps:</p>

<ol>
  <li>Remove the password settings from your macOS compliance policy. Take <code class="language-plaintext highlighter-rouge">passwordRequired</code> and its companions out so the policy stops implicitly enabling Change at next authentication.</li>
  <li>Recreate the requirement in a Settings Catalog passcode profile. The Settings Catalog is applied declaratively through DDM, not enforced the way a compliance policy is, so it sets your rules without flipping the forced-change flag against managed accounts. The one setting that clears the lockout is Change at next authentication, set explicitly off (shown here as the single setting fragment, not a complete policy you can import on its own):</li>
</ol>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"settingDefinitionId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"passcode_changeatnextauth"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"choiceSettingValue"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"passcode_changeatnextauth_false"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Moving to DDM costs you no enforcement. The same declarative profile still lets you require and shape the passcode: demand it exists, force alphanumeric complexity, set a minimum length, lock out after failed attempts, expire passwords on a schedule, and block reuse. Set the values to match your own baseline; the only one that matters for this bug is <code class="language-plaintext highlighter-rouge">passcode_changeatnextauth</code>, kept off. Here are a few examples:</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>What it controls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_requirepasscode</code></td>
      <td>Whether a passcode is required at all</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_requirealphanumericpasscode</code></td>
      <td>Require letters and numbers, not just digits</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_minimumlength</code></td>
      <td>Minimum passcode length</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_changeatnextauth</code></td>
      <td>Force a change at next sign-in - keep this off, the key fix from this post</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_maximumfailedattempts</code></td>
      <td>Failed attempts allowed before lockout</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_failedattemptsresetinminutes</code></td>
      <td>When the failed-attempt counter resets</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_maximumgraceperiodinminutes</code></td>
      <td>Grace period before a passcode is required again on wake</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_maximuminactivityinminutes</code></td>
      <td>Idle time before the screen locks</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_maximumpasscodeageindays</code></td>
      <td>Maximum passcode age before it expires</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">passcode_passcodereuselimit</code></td>
      <td>How many previous passcodes are blocked from reuse</td>
    </tr>
  </tbody>
</table>

<p>With the requirement delivered this way, rotate the admin password once more from Intune and confirm it authenticates before you need it under pressure:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>% dscl <span class="nb">.</span> <span class="nt">-authonly</span> &lt;admin-name&gt; <span class="s1">'&lt;password-from-intune&gt;'</span>
%
</code></pre></div></div>

<p>No output, exit 0. That is the only proof that matters. Make this check part of your routine after every rotation and on every new macOS release.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The lesson is smaller than the symptom suggests: a compliance policy is enforcement, not an audit. On the Mac that distinction is expensive, because Open Directory applies password rules to every managed account and the LAPS admin gets caught in it. This is not a reason to avoid compliance policies, it is a reason to be deliberate about where the password requirement lives. If rotating the admin password by hand when you take over a device fits your workflow, that is a valid choice, just be clear on who in your RBAC model actually holds that permission. My own preference is to push the requirement declaratively through DDM (the Settings Catalog), with Change at next authentication off, so the forced change never happens and there is nothing to rotate after the fact. Either way, verify with <code class="language-plaintext highlighter-rouge">dscl . -authonly</code> after each rotation.</p>

<p>This is a snapshot of macOS 26.5.x behavior. The ADE enrollment variant is resolved in macOS 26.4 (per Microsoft Learn), but the underlying enforcement still comes from Apple’s Open Directory, so re-test whenever you move to a new major release.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/macos-onedrive-open-at-login/">OneDrive Open at Login on macOS: Why It Stopped Working</a> - another green-in-Intune macOS setting that silently stopped doing anything.</li>
  <li><a href="/posts/macpppc-deploy-macos-pppc-profiles-to-intune/">MacPPPC: Build and Deploy macOS PPPC Profiles to Intune from Your Browser</a> - another corner of macOS-via-Intune management where Apple’s MDM rules bite if you get the details wrong.</li>
  <li><a href="/posts/forgotten-features-series-part-4-beyond-built-in-checks-intune-custom-compliance-policies/">Forgotten Features, Part 4: Intune Custom Compliance Policies</a> - more on what Intune compliance policies can and cannot do, and how to extend them with custom checks.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/intune/device-security/laps/setup-macos">Microsoft Learn - Configure macOS ADE local account configuration with LAPS</a></li>
  <li><a href="https://learn.microsoft.com/en-us/intune/device-security/compliance/ref-macos-settings">Microsoft Learn - macOS device compliance settings in Intune</a></li>
  <li><a href="https://kb.omnissa.com/s/article/89299">Omnissa KB 89299 - Managed Admin Passwords Fail to Authenticate macOS Devices</a> (Workspace ONE - same symptom)</li>
  <li><a href="https://community.jamf.com/general-discussions-2/admin-laps-password-not-working-32231">Jamf community - admin LAPS password not working</a> (Jamf Pro - community report of the same symptom)</li>
  <li><a href="https://github.com/royklo/IntuneSettingsCatalogData/blob/main/data/settings.json">IntuneSettingsCatalogData - passcode setting definitions</a></li>
  <li><code class="language-plaintext highlighter-rouge">man dscl</code> and <code class="language-plaintext highlighter-rouge">man dseditgroup</code> (local macOS reference)</li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Intune" />
      
        <category term="Compliance" />
      
        <category term="Security" />
      
        <category term="M365" />
      
        <category term="Troubleshooting" />
      

      
      
        <summary type="html"><![CDATA[A macOS LAPS admin password gets rejected as if it were wrong. The usual cause is an Intune compliance policy enforcing a password change. How to spot and fix it.]]></summary>
      
    </entry>
  
    <entry>
      

      <title type="html">InforcerCommunity: A PowerShell Module for the Inforcer REST API</title>
      <link href="https://rksolutions.nl/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api/" rel="alternate" type="text/html" title="InforcerCommunity: A PowerShell Module for the Inforcer REST API" />
      <published>2026-06-30T00:00:00+00:00</published>
      <updated>2026-06-30T00:00:00+00:00</updated>
      <id>https://rksolutions.nl/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api</id>
      <content type="html" xml:base="https://rksolutions.nl/posts/inforcercommunity-a-powershell-module-for-the-inforcer-api/"><![CDATA[<p>If you use <strong>Inforcer</strong> to manage Microsoft 365 baselines, alignment scores, and policies across tenants, you already know the value of a single pane of glass for compliance and policy drift. But automation and scripting often mean wrestling with REST APIs, building your own auth and error handling, and maintaining scripts that break when the API changes. <strong>InforcerCommunity</strong> is a community PowerShell module that wraps the Inforcer API so you can connect, query tenants, baselines, policies, alignment details, users, and audit events from the command line or from your own scripts - with consistent parameters, sensible defaults, and help that works. This guide explains what it does, how to use it, and how you can contribute or ask for new features.</p>

<p><strong>Table of Contents</strong></p>

<ul>
  <li><a href="#what-is-inforcercommunity">What is InforcerCommunity?</a></li>
  <li><a href="#requirements">Requirements</a></li>
  <li><a href="#installation">Installation</a>
    <ul>
      <li><a href="#option-1-from-powershell-gallery-recommended">Option 1: From PowerShell Gallery (recommended)</a></li>
      <li><a href="#option-2-from-source-github">Option 2: From source (GitHub)</a></li>
    </ul>
  </li>
  <li><a href="#quick-start">Quick Start</a></li>
  <li><a href="#reports">Reports</a>
    <ul>
      <li><a href="#discovering-report-types">Discovering Report Types</a></li>
      <li><a href="#running-a-report">Running a Report</a></li>
      <li><a href="#execution-modes">Execution Modes</a></li>
      <li><a href="#pipeline-and-bulk-runs">Pipeline and Bulk Runs</a></li>
    </ul>
  </li>
  <li><a href="#assessments">Assessments</a>
    <ul>
      <li><a href="#single-tenant-assessment">Single-Tenant Assessment</a></li>
      <li><a href="#multi-tenant-assessment-matrix">Multi-Tenant Assessment Matrix</a></li>
      <li><a href="#assessment-export-options">Assessment Export Options</a></li>
    </ul>
  </li>
  <li><a href="#tenant-documentation">Tenant Documentation</a></li>
  <li><a href="#environment-comparison">Environment Comparison</a></li>
  <li><a href="#key-cmdlets-and-use-cases">Key Cmdlets and Use Cases</a></li>
  <li><a href="#output-formats-and-filtering">Output Formats and Filtering</a></li>
  <li><a href="#how-to-contribute">How to Contribute</a></li>
  <li><a href="#how-to-report-bugs">How to Report Bugs</a></li>
  <li><a href="#how-to-request-a-feature">How to Request a Feature</a></li>
  <li><a href="#conclusion">Conclusion</a></li>
</ul>

<h2 id="what-is-inforcercommunity">What is InforcerCommunity?</h2>

<p><strong>InforcerCommunity</strong> is a PowerShell script module that talks to the Inforcer REST API.</p>

<p><strong>What it gives you:</strong></p>

<ul>
  <li><strong>Connect once, query everything:</strong> Authenticate with your Inforcer API key and region, then run cmdlets to list tenants, baselines, policies, alignment details, users, and audit events.</li>
  <li><strong>Consistent behavior:</strong> All Get-* cmdlets support <code class="language-plaintext highlighter-rouge">-Format</code>, <code class="language-plaintext highlighter-rouge">-OutputType</code> (PowerShellObject or JsonObject), and <code class="language-plaintext highlighter-rouge">-TenantId</code> for filtering. TenantId accepts a numeric ID, Microsoft Tenant ID (GUID), or tenant name.</li>
  <li><strong>No secrets in scripts:</strong> The API key is stored as a SecureString in the session; you can pass it once via <code class="language-plaintext highlighter-rouge">Connect-Inforcer</code> and then run as many commands as you need.</li>
  <li><strong>Tab completion and help:</strong> Every cmdlet has comment-based help; <code class="language-plaintext highlighter-rouge">Get-Help Connect-Inforcer -Full</code> and tab completion on parameters (e.g. <code class="language-plaintext highlighter-rouge">-EventType</code> on <code class="language-plaintext highlighter-rouge">Get-InforcerAuditEvent</code>) work out of the box.</li>
  <li><strong>Pipeline support:</strong> Pipe tenants into other cmdlets - e.g. <code class="language-plaintext highlighter-rouge">Get-InforcerTenant | Get-InforcerUser</code> or <code class="language-plaintext highlighter-rouge">Get-InforcerTenant | Get-InforcerTenantPolicies</code> works out of the box.</li>
</ul>

<p><strong>Where to find it:</strong></p>

<ul>
  <li><strong>Source code and issues:</strong> <a href="https://github.com/royklo/InforcerCommunity">https://github.com/royklo/InforcerCommunity</a></li>
  <li><strong>PowerShell Gallery:</strong> <a href="https://www.powershellgallery.com/packages/InforcerCommunity">https://www.powershellgallery.com/packages/InforcerCommunity</a></li>
</ul>

<blockquote>
  <p><strong>Community project notice:</strong> InforcerCommunity was created by me for the community. It is <strong>not owned, endorsed, or maintained by Inforcer</strong>. It is an independent, community-driven project to make the Inforcer API easier to use from PowerShell. You use it at your own responsibility.</p>
</blockquote>

<p>For per-version detail, see the <a href="https://github.com/royklo/InforcerCommunity/blob/main/CHANGELOG.md">CHANGELOG</a> in the repository. This post focuses on what the module does and how to use it.</p>

<h2 id="requirements">Requirements</h2>

<ul>
  <li><strong>PowerShell 7.0 or later</strong> (Windows, macOS, or Linux).</li>
  <li>An <strong>Inforcer API key</strong> from your Inforcer tenant (<strong>Configure &gt; REST API &gt; New API Key</strong>).</li>
</ul>

<h2 id="installation">Installation</h2>

<h3 id="option-1-from-powershell-gallery-recommended">Option 1: From PowerShell Gallery (recommended)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Install-Module -Name InforcerCommunity -Scope CurrentUser
</code></pre></div></div>

<h3 id="option-2-from-source-github">Option 2: From source (GitHub)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/royklo/InforcerCommunity.git
cd InforcerCommunity
Import-Module ./module/InforcerCommunity.psd1 -Force
</code></pre></div></div>

<p><strong>Important:</strong> When loading from source, always run <code class="language-plaintext highlighter-rouge">Import-Module</code> from the <strong>repository root</strong> and use the path <code class="language-plaintext highlighter-rouge">./module/InforcerCommunity.psd1</code>. If you see errors about a missing file or wrong path, make sure you are in the InforcerCommunity repo root.</p>

<h2 id="quick-start">Quick Start</h2>

<p>After installing the module:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Connect with your API key (region: uk, eu, us, or anz)
Connect-Inforcer -ApiKey "your-api-key" -Region uk

# List all tenants you have access to
Get-InforcerTenant

# Get alignment details in table format
Get-InforcerAlignmentDetails

# Get users for a tenant (by name, numeric ID, or GUID)
Get-InforcerUser -TenantId "Contoso"

# Get policies for a specific tenant
Get-InforcerTenantPolicies -TenantId 482

# Disconnect when done
Disconnect-Inforcer
</code></pre></div></div>

<p>You can use <code class="language-plaintext highlighter-rouge">Get-Help &lt;CmdletName&gt; -Full</code> for parameters and examples (e.g. <code class="language-plaintext highlighter-rouge">Get-Help Get-InforcerUser -Full</code>).</p>

<h2 id="reports">Reports</h2>

<p>Inforcer’s Reports API generates ready-made tenant reports - Active User Count, Tenant Audit Report, Copilot Adoption, and more. InforcerCommunity wraps the full lifecycle (discover, queue, poll, download) in four cmdlets, with <code class="language-plaintext highlighter-rouge">Invoke-InforcerReport</code> doing the heavy lifting end-to-end.</p>

<p>The two API scopes you need are <code class="language-plaintext highlighter-rouge">Reports.Read</code> (to discover types and download outputs) and <code class="language-plaintext highlighter-rouge">Reports.Run</code> (to queue runs). If you target a tenant by GUID or name instead of its numeric Client Tenant ID, you also need <code class="language-plaintext highlighter-rouge">Tenants.Read</code>. <code class="language-plaintext highlighter-rouge">Connect-Inforcer</code> accepts any valid API key regardless of scope, so a <code class="language-plaintext highlighter-rouge">Reports.Read</code>-only key works fine for read-only workflows. On HTTP 401 it reports a meaningful message instead of a raw status code, and every API error surfaces field-level detail from the server’s <code class="language-plaintext highlighter-rouge">errors[]</code> array along with the <code class="language-plaintext highlighter-rouge">x-correlation-id</code> header for support tickets.</p>

<h3 id="discovering-report-types">Discovering Report Types</h3>

<p>Start by listing what’s available. The catalog is cached after the first call, so this is instant on repeat and powers tab completion on <code class="language-plaintext highlighter-rouge">-ReportType</code>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># List every available report type</span><span class="w">
</span><span class="n">Get-InforcerReportType</span><span class="w">

</span><span class="c"># Filter to a single type by key</span><span class="w">
</span><span class="n">Get-InforcerReportType</span><span class="w"> </span><span class="nt">-Key</span><span class="w"> </span><span class="nx">CopilotAdoption</span><span class="w">

</span><span class="c"># Only types that support PDF output</span><span class="w">
</span><span class="n">Get-InforcerReportType</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">pdf</span><span class="w">

</span><span class="c"># Only types tagged 'security'</span><span class="w">
</span><span class="n">Get-InforcerReportType</span><span class="w"> </span><span class="nt">-Tag</span><span class="w"> </span><span class="nx">security</span><span class="w">
</span></code></pre></div></div>

<h3 id="running-a-report">Running a Report</h3>

<p>The simplest run queues a report, waits for it to finish, and saves the output to your current directory:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Queue, wait, and save ActiveUserCount.csv to the current directory</span><span class="w">
</span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">ActiveUserCount</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">csv</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w">

</span><span class="c"># Save somewhere specific and open it as soon as it lands</span><span class="w">
</span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">TenantAuditReport</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">html</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/reports</span><span class="w"> </span><span class="nt">-Open</span><span class="w">
</span></code></pre></div></div>

<p>While it works, you get a three-phase progress bar - queue, then poll (with elapsed time and poll count), then download. The cmdlet accepts a tenant as a numeric Client Tenant ID, a Microsoft Tenant GUID, or a tenant name, and resolves it before submitting. If you reference a report type that isn’t in your cached catalog, it refreshes the catalog once before failing, so newly shipped report types work without a reconnect.</p>

<p>Some report types take a period. <code class="language-plaintext highlighter-rouge">CopilotAdoption</code> and <code class="language-plaintext highlighter-rouge">ShadowAiDetection</code> default to 30 days when you omit it; pass <code class="language-plaintext highlighter-rouge">-ReportPeriod</code> to override. To run a saved assessment as a report, use <code class="language-plaintext highlighter-rouge">-ReportType Assessment</code> with an <code class="language-plaintext highlighter-rouge">-AssessmentId</code> (tab completion accepts the friendly name and inserts the opaque ID):</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">Assessment</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="nx">l1f8wd29pl44pp1j66r9</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">pdf</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w">
</span></code></pre></div></div>

<h3 id="execution-modes">Execution Modes</h3>

<p><code class="language-plaintext highlighter-rouge">Invoke-InforcerReport</code> has three modes depending on how much you want it to do:</p>

<ul>
  <li><strong>Default</strong> - sync and save. Polls until each run is terminal, downloads every output, and emits one result object per saved file.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">-NoWait</code></strong> - fire and forget. Returns the run IDs immediately so you can check back later with <code class="language-plaintext highlighter-rouge">Get-InforcerReportRun</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">-NoSave</code></strong> - poll until complete but write nothing. Returns the outputs metadata, ready to pipe into <code class="language-plaintext highlighter-rouge">Save-InforcerReportOutput</code> when you decide where the files should go.</li>
</ul>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Fire and forget: returns RunIds immediately</span><span class="w">
</span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">CopilotAdoption</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">csv</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="p">,</span><span class="w"> </span><span class="nx">139</span><span class="w"> </span><span class="nt">-NoWait</span><span class="w">

</span><span class="c"># Poll without saving, then download on your terms</span><span class="w">
</span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">ActiveUserCount</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">csv</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w"> </span><span class="nt">-NoSave</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Save-InforcerReportOutput</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/reports</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Get-InforcerReportRun</code> lists recent runs and, with <code class="language-plaintext highlighter-rouge">-RunId -Wait</code>, polls a single run to completion - handy because freshly queued runs take about four minutes to appear in the list endpoint, while the polling path sees them as soon as they finish.</p>

<h3 id="pipeline-and-bulk-runs">Pipeline and Bulk Runs</h3>

<p>Because <code class="language-plaintext highlighter-rouge">Get-InforcerReportType</code> emits a <code class="language-plaintext highlighter-rouge">Key</code> alias that binds to <code class="language-plaintext highlighter-rouge">-ReportType</code>, you can discover and run in one line. The same <code class="language-plaintext highlighter-rouge">OutputFormat</code> broadcasts across every piped type:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run every security-tagged report type for a tenant, all as CSV</span><span class="w">
</span><span class="n">Get-InforcerReportType</span><span class="w"> </span><span class="nt">-Tag</span><span class="w"> </span><span class="nx">Security</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">csv</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w">

</span><span class="c"># Pair report types to formats by index</span><span class="w">
</span><span class="n">Invoke-InforcerReport</span><span class="w"> </span><span class="nt">-ReportType</span><span class="w"> </span><span class="nx">TenantAuditReport</span><span class="p">,</span><span class="w"> </span><span class="nx">ActiveUserCount</span><span class="w"> </span><span class="nt">-OutputFormat</span><span class="w"> </span><span class="nx">pdf</span><span class="p">,</span><span class="w"> </span><span class="nx">csv</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">482</span><span class="w">

</span><span class="c"># Bulk-download every output from every visible run</span><span class="w">
</span><span class="n">Get-InforcerReportRun</span><span class="w"> </span><span class="nt">-IncludeOutputs</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">outputs</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Save-InforcerReportOutput</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/bulk</span><span class="w">
</span></code></pre></div></div>

<p>A few client-side guards keep you out of known API edge cases: duplicate (type, format) pairs are removed before the request (the server would otherwise render duplicates in the wrong format), <code class="language-plaintext highlighter-rouge">-Collate</code> is rejected on report types the catalog marks as non-collatable, and unknown <code class="language-plaintext highlighter-rouge">-Parameter</code> keys are rejected up front instead of being silently ignored.</p>

<h2 id="assessments">Assessments</h2>

<p>Inforcer provides compliance assessments that evaluate your tenant against industry frameworks and readiness checks - things like Copilot Readiness, CIS Microsoft 365 Foundations Benchmark, CIS Microsoft Intune for Windows 11 Benchmark, and Essential Eight Maturity Level 1. With InforcerCommunity, you can run these assessments from the command line and get structured results you can automate with.</p>

<h3 id="single-tenant-assessment">Single-Tenant Assessment</h3>

<p>Run an assessment against one tenant and see every check with its pass/fail status:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># List available assessments</span><span class="w">
</span><span class="n">Get-InforcerAssessment</span><span class="w">

</span><span class="c"># Run Copilot Readiness against a tenant (by name)</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w">
</span></code></pre></div></div>

<p>The output shows a compliance summary followed by each check as a pipeline object:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  Copilot Readiness - 76.2% compliant (16/21 checks passed)

Status           : Pass
name             : Enable Conditional Access policies to block legacy authentication
category         : Entra
subCategory      : Conditional Access
importance       : High
ObjectsEvaluated : 13
FindingsMessage  : 1 out of 13 object(s) are fully-compliant with this check
Scores           : {@{objectId=de66f385...; score=100; objectName=Core - Block - Legacy Authentication; ...}, ...}
</code></pre></div></div>

<p>Each check includes a <code class="language-plaintext highlighter-rouge">Scores</code> property with per-object detail - which Conditional Access policy passed, which failed, and why. You can drill into this:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$results</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w">

</span><span class="c"># See only failed checks</span><span class="w">
</span><span class="nv">$results</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="nx">Status</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s1">'Fail'</span><span class="w">

</span><span class="c"># Table view</span><span class="w">
</span><span class="nv">$results</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-Table</span><span class="w"> </span><span class="nx">Status</span><span class="p">,</span><span class="w"> </span><span class="nx">name</span><span class="p">,</span><span class="w"> </span><span class="nx">category</span><span class="p">,</span><span class="w"> </span><span class="nx">importance</span><span class="w">

</span><span class="c"># Drill into violations for a specific check</span><span class="w">
</span><span class="nv">$results</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">Scores</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">violations</span><span class="o">.</span><span class="nf">Count</span><span class="w"> </span><span class="o">-gt</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">objectName</span><span class="p">,</span><span class="w"> </span><span class="nx">score</span><span class="p">,</span><span class="w"> </span><span class="nx">violations</span><span class="w">
</span></code></pre></div></div>

<p>Generate an interactive HTML report with one parameter:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/copilot-report.html</span><span class="w">
</span></code></pre></div></div>

<p>The HTML report includes a navy cover banner, compliance score ring, collapsible check cards grouped by category (Entra, Exchange, M365, Purview, SharePoint), per-object expandable cards showing violations and passes, and markdown-rendered description and remediation steps. Everything is self-contained - no external dependencies, works offline.</p>

<h3 id="multi-tenant-assessment-matrix">Multi-Tenant Assessment Matrix</h3>

<p>The real power comes when you run an assessment across all your tenants at once:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run against all tenants</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-MultiTenant</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/matrix.html</span><span class="w">

</span><span class="c"># Or pick specific tenants by name</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="p">,</span><span class="s2">"Fabrikam"</span><span class="p">,</span><span class="s2">"Woodgrove"</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/matrix.html</span><span class="w">
</span></code></pre></div></div>

<p>The cmdlet runs each tenant sequentially with progress updates:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Multi-tenant assessment: 'Copilot Readiness' across 10 tenant(s)

[1/10] Running 'Copilot Readiness' against Contoso...
  Still running... 10s elapsed
  Completed in 14s.
[2/10] Running 'Copilot Readiness' against Fabrikam...
  Still running... 3m 10s elapsed
  Completed in 3m 16s.
...

All assessments complete. 10 tenant(s) processed in 16m 18s.
  Contoso - 76.2% (16/21)
  Fabrikam - 42.9% (9/21)
  Woodgrove - 38.1% (8/21)
  ...
</code></pre></div></div>

<p>The matrix HTML report is a full-viewport interactive dashboard: a sticky left column with check names that stays visible while you scroll horizontally across tenant columns, each showing a pass/fail indicator. A tenant filter dropdown lets you show or hide specific tenants (useful when you have 100+ tenants and want to focus on a subset). Click “Details” on any check to open a slide-out panel with the description, impact, and rationale. Category rows group checks by Entra, Exchange, M365, Purview, and SharePoint. Search and status filters (All, Has Failures, All Passed) work across the entire matrix.</p>

<h3 id="assessment-export-options">Assessment Export Options</h3>

<p>All export formats work for both single-tenant and multi-tenant:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># HTML report (single or matrix)</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/report.html</span><span class="w">

</span><span class="c"># CSV for Excel or automation (multi-tenant includes Tenant column)</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-MultiTenant</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="o">.</span><span class="nx">/matrix.csv</span><span class="w">

</span><span class="c"># JSON for webhooks, APIs, or further processing</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-MultiTenant</span><span class="w"> </span><span class="nt">-OutputType</span><span class="w"> </span><span class="nx">JsonObject</span><span class="w">

</span><span class="c"># Pipeline for PowerShell automation (each check has TenantName in multi-tenant mode)</span><span class="w">
</span><span class="n">Invoke-InforcerAssessment</span><span class="w"> </span><span class="nt">-AssessmentId</span><span class="w"> </span><span class="s2">"Copilot Readiness"</span><span class="w"> </span><span class="nt">-MultiTenant</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="nx">Status</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s1">'Fail'</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Group-Object</span><span class="w"> </span><span class="nx">TenantName</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="nx">Count</span><span class="w">
</span></code></pre></div></div>

<h2 id="tenant-documentation">Tenant Documentation</h2>

<p><code class="language-plaintext highlighter-rouge">Export-InforcerTenantDocumentation</code> generates comprehensive documentation for an entire tenant in one command:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Generate HTML documentation (opens in browser automatically)</span><span class="w">
</span><span class="n">Export-InforcerTenantDocumentation</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="nx">Html</span><span class="w">

</span><span class="c"># Generate HTML and Excel, with Graph enrichment for group/filter names</span><span class="w">
</span><span class="n">Connect-Inforcer</span><span class="w"> </span><span class="nt">-ApiKey</span><span class="w"> </span><span class="s2">"your-api-key"</span><span class="w"> </span><span class="nt">-Region</span><span class="w"> </span><span class="nx">uk</span><span class="w"> </span><span class="nt">-FetchGraphData</span><span class="w">
</span><span class="n">Export-InforcerTenantDocumentation</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="nx">Html</span><span class="p">,</span><span class="nx">Excel</span><span class="w"> </span><span class="nt">-OutputPath</span><span class="w"> </span><span class="nx">C:\Reports</span><span class="w">

</span><span class="c"># Export only policies from a specific baseline</span><span class="w">
</span><span class="n">Export-InforcerTenantDocumentation</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nx">139</span><span class="w"> </span><span class="nt">-Baseline</span><span class="w"> </span><span class="s2">"Inforcer Blueprint Baseline - Tier 1"</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="nx">Html</span><span class="w">

</span><span class="c"># Filter by tag</span><span class="w">
</span><span class="n">Export-InforcerTenantDocumentation</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-Tag</span><span class="w"> </span><span class="s2">"Production"</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="nx">Markdown</span><span class="w">
</span></code></pre></div></div>

<p><strong>HTML output</strong> is a self-contained file with no external dependencies - you can email it, archive it, or open it offline. It includes:</p>

<ul>
  <li>Collapsible Product &gt; Category &gt; Policy navigation in a sidebar</li>
  <li>Real-time search with text highlighting</li>
  <li>Dark/light mode toggle (persisted in localStorage)</li>
  <li>Tag filter pills with AND/OR logic</li>
  <li>Hide empty fields and show metadata toggles</li>
  <li>Collapsible long values and a back-to-top button</li>
</ul>

<p><strong>Excel output</strong> creates a workbook with one sheet per product area. Each row is a policy with columns for category, name, description, platform, settings, and assignments - ready for filtering and analysis.</p>

<p><strong>Markdown output</strong> generates a GFM-compatible document with a table of contents and per-policy tables - useful for including in wikis or version-controlled documentation.</p>

<p><strong>Settings Catalog resolution:</strong> Both the documentation and comparison cmdlets translate raw Settings Catalog IDs into friendly names using data sourced from <a href="https://github.com/royklo/IntuneSettingsCatalogData">IntuneSettingsCatalogData</a>. The dataset (~65 MB) is downloaded and cached automatically on first use at <code class="language-plaintext highlighter-rouge">~/.inforcercommunity/data/settings.json</code> with a 24-hour refresh, so the first export is slower and every subsequent one is instant.</p>

<h2 id="environment-comparison">Environment Comparison</h2>

<p><code class="language-plaintext highlighter-rouge">Compare-InforcerEnvironments</code> compares two tenants’ Intune configurations and generates an interactive HTML report:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Compare two tenants in the same Inforcer account</span><span class="w">
</span><span class="n">Compare-InforcerEnvironments</span><span class="w"> </span><span class="nt">-SourceTenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-DestinationTenantId</span><span class="w"> </span><span class="s2">"Fabrikam"</span><span class="w">

</span><span class="c"># Compare across different Inforcer accounts with Graph enrichment</span><span class="w">
</span><span class="nv">$src</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Connect-Inforcer</span><span class="w"> </span><span class="nt">-ApiKey</span><span class="w"> </span><span class="nv">$key1</span><span class="w"> </span><span class="nt">-Region</span><span class="w"> </span><span class="nx">uk</span><span class="w"> </span><span class="nt">-PassThru</span><span class="w">
</span><span class="nv">$dst</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Connect-Inforcer</span><span class="w"> </span><span class="nt">-ApiKey</span><span class="w"> </span><span class="nv">$key2</span><span class="w"> </span><span class="nt">-Region</span><span class="w"> </span><span class="nx">eu</span><span class="w"> </span><span class="nt">-PassThru</span><span class="w">
</span><span class="n">Compare-InforcerEnvironments</span><span class="w"> </span><span class="nt">-SourceTenantId</span><span class="w"> </span><span class="nx">482</span><span class="w"> </span><span class="nt">-DestinationTenantId</span><span class="w"> </span><span class="nx">139</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-SourceSession</span><span class="w"> </span><span class="nv">$src</span><span class="w"> </span><span class="nt">-DestinationSession</span><span class="w"> </span><span class="nv">$dst</span><span class="w"> </span><span class="nt">-FetchGraphData</span><span class="w">
</span></code></pre></div></div>

<p>You can also scope either side to a single baseline with <code class="language-plaintext highlighter-rouge">-SourceBaselineId</code> and <code class="language-plaintext highlighter-rouge">-DestinationBaselineId</code>, so the comparison covers only the policies in that baseline rather than the whole tenant:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Compare only the policies in a baseline against a full tenant</span><span class="w">
</span><span class="n">Compare-InforcerEnvironments</span><span class="w"> </span><span class="nt">-SourceTenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-SourceBaselineId</span><span class="w"> </span><span class="s2">"Tier 1 - Foundations"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-DestinationTenantId</span><span class="w"> </span><span class="s2">"Fabrikam"</span><span class="w">

</span><span class="c"># Compare one baseline against another</span><span class="w">
</span><span class="n">Compare-InforcerEnvironments</span><span class="w"> </span><span class="nt">-SourceTenantId</span><span class="w"> </span><span class="s2">"Contoso"</span><span class="w"> </span><span class="nt">-SourceBaselineId</span><span class="w"> </span><span class="s2">"Tier 1"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-DestinationTenantId</span><span class="w"> </span><span class="s2">"Fabrikam"</span><span class="w"> </span><span class="nt">-DestinationBaselineId</span><span class="w"> </span><span class="s2">"Tier 2"</span><span class="w">
</span></code></pre></div></div>

<p>When you pass a baseline ID without its matching tenant ID, the cmdlet resolves the baseline’s owner tenant automatically, so <code class="language-plaintext highlighter-rouge">-SourceTenantId</code> / <code class="language-plaintext highlighter-rouge">-DestinationTenantId</code> can be omitted on that side.</p>

<p><strong>The HTML report includes four tabs:</strong></p>

<ul>
  <li><strong>Comparison</strong> - flat table of all Settings Catalog settings with sortable columns, status filter pills (Matched/Conflicting/Source Only/Dest Only), category dropdown, and advanced column filters with AND/OR logic</li>
  <li><strong>Manual Review</strong> - non-Settings-Catalog policies (compliance, enrollment, scripts) in a 50/50 source/destination layout grouped by platform. Matching policy names are aligned side-by-side. Scripts and compliance rules are shown as collapsible code blocks with syntax highlighting</li>
  <li><strong>Duplicates</strong> - settings configured in two or more policies with different values, with automated conflict analysis</li>
  <li><strong>Deprecated</strong> - settings flagged as deprecated by Microsoft, grouped by source and destination</li>
</ul>

<p><strong>Cross-category reconciliation:</strong> A setting delivered as Endpoint Security on one tenant and as a Settings Catalog policy on another would normally show up as two separate items - one “source only” and one “destination only”. The comparison matches by <code class="language-plaintext highlighter-rouge">DefinitionId</code> instead of policy category, so the same underlying setting lines up as a single row regardless of which Intune template type each tenant used to deploy it. This is what lets you compare a tenant on the older Endpoint Security flow against one that’s migrated to Settings Catalog without a wall of false-positive drift.</p>

<p>The report also features an animated configuration match score (with confetti at 100%), dark/light mode toggle, column resize handles, and a responsive layout. Like the Export report, the HTML is fully self-contained with no external dependencies.</p>

<h2 id="key-cmdlets-and-use-cases">Key Cmdlets and Use Cases</h2>

<table>
  <thead>
    <tr>
      <th>Cmdlet</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Connect-Inforcer</code></td>
      <td>Establishes a secure connection to the Inforcer API (ApiKey, Region or BaseUrl). Accepts any valid API key regardless of scope, and supports <code class="language-plaintext highlighter-rouge">-FetchGraphData</code> and <code class="language-plaintext highlighter-rouge">-PassThru</code> for cross-account workflows.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Disconnect-Inforcer</code></td>
      <td>Clears the session, drops all cached catalogs, and disconnects.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Test-InforcerConnection</code></td>
      <td>Verifies the current API connection.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerTenant</code></td>
      <td>Lists tenants; optional <code class="language-plaintext highlighter-rouge">-TenantId</code> to return a single tenant.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerBaseline</code></td>
      <td>Retrieves baseline groups and members.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerTenantPolicies</code></td>
      <td>Retrieves policies for a given tenant.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerAlignmentDetails</code></td>
      <td>Retrieves alignment scores or per-policy alignment details (optional <code class="language-plaintext highlighter-rouge">-TenantId</code>, <code class="language-plaintext highlighter-rouge">-BaselineId</code>, <code class="language-plaintext highlighter-rouge">-Tag</code>).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerAuditEvent</code></td>
      <td>Retrieves audit events (optional <code class="language-plaintext highlighter-rouge">-EventType</code>, date range, paging).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerSupportedEventType</code></td>
      <td>Lists supported audit event types (used for tab completion).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerUser</code></td>
      <td>Lists/searches users or gets full user detail by ID (optional <code class="language-plaintext highlighter-rouge">-Search</code>, <code class="language-plaintext highlighter-rouge">-MaxResults</code>, <code class="language-plaintext highlighter-rouge">-UserId</code>).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerGroup</code></td>
      <td>Retrieves Entra ID groups (list with search/filter/pagination, or detail by name/GUID with members).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerRole</code></td>
      <td>Retrieves Entra ID directory role definitions (built-in, enabled, privileged).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Export-InforcerTenantDocumentation</code></td>
      <td>Generates tenant documentation in HTML, Markdown, or Excel (optional <code class="language-plaintext highlighter-rouge">-Baseline</code>, <code class="language-plaintext highlighter-rouge">-Tag</code>, <code class="language-plaintext highlighter-rouge">-FetchGraphData</code>).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Compare-InforcerEnvironments</code></td>
      <td>Compares two tenants’ Intune configuration and generates an interactive HTML comparison report. Supports baseline-scoped comparison via <code class="language-plaintext highlighter-rouge">-SourceBaselineId</code> / <code class="language-plaintext highlighter-rouge">-DestinationBaselineId</code>.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerAssessment</code></td>
      <td>Lists available assessments (Copilot Readiness, CIS Benchmarks, Essential Eight, etc.).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Invoke-InforcerAssessment</code></td>
      <td>Runs an assessment against one or more tenants. Supports <code class="language-plaintext highlighter-rouge">-MultiTenant</code>, <code class="language-plaintext highlighter-rouge">-OutputPath</code> (HTML/CSV), <code class="language-plaintext highlighter-rouge">-OutputType JsonObject</code>.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerReportType</code></td>
      <td>Lists available report types with supported formats and tags (cached, drives tab completion).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Invoke-InforcerReport</code></td>
      <td>Queues report runs, polls until complete, and saves the output. Supports <code class="language-plaintext highlighter-rouge">-NoWait</code>, <code class="language-plaintext highlighter-rouge">-NoSave</code>, <code class="language-plaintext highlighter-rouge">-Open</code>, and pipeline input.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Get-InforcerReportRun</code></td>
      <td>Lists recent report runs; <code class="language-plaintext highlighter-rouge">-Wait</code> polls a run to completion, <code class="language-plaintext highlighter-rouge">-IncludeOutputs</code> embeds outputs.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Save-InforcerReportOutput</code></td>
      <td>Downloads a specific report output to disk using the server-suggested filename.</td>
    </tr>
  </tbody>
</table>

<p><strong>Typical workflows:</strong></p>

<ul>
  <li><strong>Tenant and policy overview:</strong> <code class="language-plaintext highlighter-rouge">Connect-Inforcer</code> -&gt; <code class="language-plaintext highlighter-rouge">Get-InforcerTenant</code> -&gt; <code class="language-plaintext highlighter-rouge">Get-InforcerTenantPolicies -TenantId "Contoso"</code> to inspect a specific tenant’s policies.</li>
  <li><strong>Alignment and drift:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerAlignmentDetails</code> for score summaries; <code class="language-plaintext highlighter-rouge">Get-InforcerAlignmentDetails -BaselineId "Tier 0"</code> for per-policy detail.</li>
  <li><strong>User overview:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerUser -TenantId "Contoso"</code> for a user list; <code class="language-plaintext highlighter-rouge">Get-InforcerUser -TenantId 139 -UserId "8e61ce11-..."</code> for full detail including groups, roles, devices, and risk.</li>
  <li><strong>Audit and compliance:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerAuditEvent</code> with optional <code class="language-plaintext highlighter-rouge">-EventType</code> (tab completion for event types), <code class="language-plaintext highlighter-rouge">-DateFrom</code>, <code class="language-plaintext highlighter-rouge">-DateTo</code>, and paging parameters.</li>
  <li><strong>Tenant documentation:</strong> <code class="language-plaintext highlighter-rouge">Export-InforcerTenantDocumentation -TenantId "Contoso" -Format Html,Excel</code> to generate a complete configuration snapshot.</li>
  <li><strong>Environment comparison:</strong> <code class="language-plaintext highlighter-rouge">Compare-InforcerEnvironments -SourceTenantId "Contoso" -DestinationTenantId "Fabrikam"</code> to see every difference between two tenants.</li>
  <li><strong>Group and role lookup:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerGroup -TenantId 139 -Search "Finance"</code> for groups, <code class="language-plaintext highlighter-rouge">Get-InforcerRole -TenantId 139 | Where-Object IsPrivileged -eq $true</code> for privileged roles.</li>
  <li><strong>Compliance assessment:</strong> <code class="language-plaintext highlighter-rouge">Invoke-InforcerAssessment -TenantId "Contoso" -AssessmentId "Copilot Readiness"</code> to check a single tenant, or add <code class="language-plaintext highlighter-rouge">-MultiTenant -OutputPath matrix.html</code> for a cross-tenant matrix report.</li>
  <li><strong>Reports:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerReportType</code> to see what’s available, then <code class="language-plaintext highlighter-rouge">Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId "Contoso" -Open</code> to queue, download, and open it; pipe <code class="language-plaintext highlighter-rouge">Get-InforcerReportType -Tag Security | Invoke-InforcerReport -OutputFormat csv -TenantId 482</code> to run a whole category at once.</li>
  <li><strong>Pipeline:</strong> <code class="language-plaintext highlighter-rouge">Get-InforcerTenant -TenantId 139 | Get-InforcerUser</code> to list users for a piped tenant.</li>
</ul>

<p>For full parameter details and example output, see the <a href="https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md">Cmdlet Reference</a> in the repository.</p>

<h2 id="output-formats-and-filtering">Output Formats and Filtering</h2>

<ul>
  <li><strong>-Format:</strong> Most Get-* cmdlets support <code class="language-plaintext highlighter-rouge">Table</code> or <code class="language-plaintext highlighter-rouge">Raw</code> (e.g. for alignment details).</li>
  <li><strong>-OutputType:</strong> <code class="language-plaintext highlighter-rouge">PowerShellObject</code> (default) or <code class="language-plaintext highlighter-rouge">JsonObject</code> (JSON with depth 100) for piping into other tools or export.</li>
  <li><strong>-TenantId:</strong> Accepts a numeric Client Tenant ID, a Microsoft Tenant ID (GUID), or a tenant name (case-insensitive match). Use it on <code class="language-plaintext highlighter-rouge">Get-InforcerTenant</code>, <code class="language-plaintext highlighter-rouge">Get-InforcerTenantPolicies</code>, <code class="language-plaintext highlighter-rouge">Get-InforcerAlignmentDetails</code>, <code class="language-plaintext highlighter-rouge">Get-InforcerUser</code>, and others.</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Example: export all tenants as JSON for use elsewhere
Get-InforcerTenant -OutputType JsonObject | Out-File tenants.json -Encoding utf8

# Example: search users by name
Get-InforcerUser -TenantId "Contoso" -Search "Adele"

# Example: get full user detail as JSON
Get-InforcerUser -TenantId 139 -UserId "8e61ce11-a45b-42a6-8ca4-1d881781566d" -OutputType JsonObject
</code></pre></div></div>

<h2 id="how-to-contribute">How to Contribute</h2>

<p>Contributions are welcome. The project uses a standard fork-and-pull-request workflow:</p>

<ul>
  <li><strong>Fork</strong> the repository on GitHub: <a href="https://github.com/royklo/InforcerCommunity">https://github.com/royklo/InforcerCommunity</a>.</li>
  <li><strong>Clone your fork</strong> and create a branch (e.g. <code class="language-plaintext highlighter-rouge">feature/your-feature-name</code> or <code class="language-plaintext highlighter-rouge">fix/bug-description</code>).</li>
  <li><strong>Make your changes</strong> under <code class="language-plaintext highlighter-rouge">module/</code> (see <a href="https://github.com/royklo/InforcerCommunity/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a> for code style and the consistency contract - parameter order, <code class="language-plaintext highlighter-rouge">-Format</code>/<code class="language-plaintext highlighter-rouge">-OutputType</code>, property names, etc.).</li>
  <li><strong>Run tests</strong> from the repo root: <code class="language-plaintext highlighter-rouge">Invoke-Pester ./Tests/Consistency.Tests.ps1</code>.</li>
  <li><strong>Commit and push</strong> to your fork, then open a <strong>pull request</strong> against the main repository. Fill in the PR template (summary, how to test, related issue if any).</li>
</ul>

<p>New cmdlets must be added to <code class="language-plaintext highlighter-rouge">module/Public/</code>, registered in <code class="language-plaintext highlighter-rouge">FunctionsToExport</code> in the manifest, and documented in <code class="language-plaintext highlighter-rouge">docs/CMDLET-REFERENCE.md</code>. The consistency tests must be updated if you add or change exported cmdlets or key parameters.</p>

<h2 id="how-to-report-bugs">How to Report Bugs</h2>

<p>If something doesn’t work as expected:</p>

<ul>
  <li>Go to <a href="https://github.com/royklo/InforcerCommunity/issues/new">New issue</a>.</li>
  <li>Choose <strong>Bug report</strong>.</li>
  <li>Fill in:</li>
</ul>

<p><strong>Description</strong>: What went wrong?</p>

<ul>
  <li><strong>Steps to reproduce</strong>: Exact commands or steps.</li>
  <li><strong>Expected behavior</strong>: What you expected.</li>
  <li><strong>Actual behavior</strong>: What happened instead (including any error messages).</li>
  <li><strong>Environment</strong>: PowerShell version, OS, and module version (e.g. <code class="language-plaintext highlighter-rouge">Get-Module InforcerCommunity | Select-Object Version</code>).</li>
  <li><strong>Additional context</strong>: Logs, screenshots, or other details.</li>
</ul>

<p>This helps maintainers and the community reproduce and fix issues quickly.</p>

<h2 id="how-to-request-a-feature">How to Request a Feature</h2>

<p>Have an idea for a new cmdlet, parameter, or behaviour?</p>

<ul>
  <li>Go to <a href="https://github.com/royklo/InforcerCommunity/issues/new">New issue</a>.</li>
  <li>Choose <strong>Feature request</strong>.</li>
  <li>Describe:</li>
</ul>

<p>The <strong>feature</strong> you’d like (e.g. a new endpoint, a new parameter, or a different output shape).</p>

<ul>
  <li>The <strong>use case</strong> (why it would help you or others).</li>
  <li>If you have one, a <strong>proposed solution</strong> (e.g. cmdlet name, parameters, example usage).</li>
</ul>

<p>Not every request can be implemented immediately, but all are read and considered; they also help others discover and discuss ideas.</p>

<h2 id="conclusion">Conclusion</h2>

<p>InforcerCommunity turns the Inforcer API into a set of PowerShell cmdlets you can use interactively or in scripts: connect once, then list tenants, baselines, policies, alignment details, users, groups, roles, and audit events with consistent parameters and output. Generate Inforcer reports like Active User Count, Tenant Audit, and Copilot Adoption straight to disk with a single cmdlet that queues, polls, and downloads for you. Run compliance assessments like Copilot Readiness and CIS Benchmarks against one tenant or all of them at once, with interactive HTML matrix reports that let you compare compliance across your entire estate. Generate complete tenant documentation in HTML, Markdown, or Excel. Compare two tenants’ Intune configurations side-by-side with an interactive report that shows every difference, duplicate, and deprecated setting. Use tenant names instead of IDs, pipe results between cmdlets, and export to CSV or JSON for integration with other tools and automation pipelines. It’s a community project, not owned or maintained by Inforcer; feedback, bug reports, and feature requests from users like you shape what comes next. Install it from the <a href="https://www.powershellgallery.com/packages/InforcerCommunity">PowerShell Gallery</a>, try the <a href="#quick-start">quick start</a>, and if you hit a bug or have an idea, open an <a href="https://github.com/royklo/InforcerCommunity/issues">issue</a> or send a <a href="https://github.com/royklo/InforcerCommunity/pulls">pull request</a>.</p>

<h2 id="related">Related</h2>

<ul>
  <li><a href="/posts/inforcercommunity-mcp/">InforcerCommunity MCP: Ask Your AI Assistant About Your Inforcer Tenants</a> - the same API, reached from an AI assistant instead of the command line.</li>
  <li><a href="/posts/msp-license-ladder-01-hunting-gap/">The MSP License Ladder #1: The Hunting Gap</a> - why MSPs need Defender for Endpoint Plan 2, with a multi-tenant hunting script.</li>
  <li><a href="/posts/rksolutions-powershell-module/">RKSolutions PowerShell Module</a> - another PowerShell module for M365 reporting across Intune, Entra ID, and license management.</li>
</ul>]]></content>

      
      
      <author>
          <name>Roy Klooster</name>
      </author>

      
        <category term="Conditional Access" />
      
        <category term="Exchange Online" />
      
        <category term="Entra ID" />
      
        <category term="Graph API" />
      
        <category term="Intune" />
      
        <category term="Reports" />
      
        <category term="Tools" />
      

      
      
        <summary type="html"><![CDATA[InforcerCommunity is a community-built PowerShell module for the Inforcer REST API. Queue and download tenant reports (Active User Count, Tenant Audit, Copilot Adoption), run compliance assessments like Copilot Readiness and CIS Benchmarks across one tenant or many, export full tenant documentation in HTML/Markdown/Excel, and compare two tenants' Intune configuration side-by-side. Designed for MSPs managing multiple M365 tenants through Inforcer.]]></summary>
      
    </entry>
  
</feed>
