<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Do it PowerShell way :)]]></title><description><![CDATA[With over 15 years of experience as a system administrator, I have a passion for automating workflows using PowerShell. I believe in sharing my creations with t]]></description><link>https://doitpshway.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1619199319514/zHJdiNa1NF.png</url><title>Do it PowerShell way :)</title><link>https://doitpshway.com</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 21 Apr 2026 12:59:36 GMT</lastBuildDate><atom:link href="https://doitpshway.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Automating Azure PIM role activation secured with FIDO2/Passkey with PowerShell]]></title><description><![CDATA[If you invoke some Azure or Graph Api call and see an error similar to this one (RoleAssignmentRequestAcrsValidationFailed) 👇, you should read this post 🙂.

The error code RoleAssignmentRequestAcrsValidationFailed, followed by the failed claim clai...]]></description><link>https://doitpshway.com/automating-azure-pim-role-activation-secured-with-fido2passkey-with-powershell</link><guid isPermaLink="true">https://doitpshway.com/automating-azure-pim-role-activation-secured-with-fido2passkey-with-powershell</guid><category><![CDATA[Azure]]></category><category><![CDATA[claims]]></category><category><![CDATA[authentication]]></category><category><![CDATA[azure-conditional-access ]]></category><category><![CDATA[Powershell]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Thu, 11 Dec 2025 14:16:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765450686226/5fec87c6-b32d-4d78-8923-b85811ae52f5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you invoke some Azure or Graph Api call and see an error similar to this one (<strong>RoleAssignmentRequestAcrsValidationFailed</strong>) 👇, you should read this post 🙂.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765449952157/eec35ec8-ba8a-42e4-b3c3-2de887ed8e2c.png" alt class="image--center mx-auto" /></p>
<p>The error code <strong>RoleAssignmentRequestAcrsValidationFailed</strong>, followed by the failed claim <code>claims=%7B%22access_token%22%3A%7B%22acrs%22%3A%7B%22essential%22%3Atrue%2C%20%22value%22%3A%22c1%22%7D%7D%7D</code> (unescaped it looks like this <code>claims={"access_token":{"acrs":{"essential":true, "value":"c1"}}}</code>) means the method used for authenticating the current session isn’t sufficient to run the selected action.</p>
<p>For me, it was when I activated <strong>the PIM-protected Azure Directory Role that required FIDO2 (Passkey)</strong> authentication strength (enforced via <a target="_blank" href="https://learn.microsoft.com/en-us/graph/identity-governance-pim-rules-overview?toc=%2Fentra%2Fidentity%2Fprivileged-identity-management%2Ftoc.json&amp;bc=%2Fentra%2Fidentity%2Fid-governance%2Fprivileged-identity-management%2Fbreadcrumb%2Ftoc.json#mapping-of-rule-ids-to-pim-role-settings-on-the-microsoft-entra-admin-center">Azure Conditional Access Policy</a>).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765453729310/d164746b-e70d-4037-98ae-25c4f1429a21.png" alt class="image--center mx-auto" /></p>
<p>Anyway, if something like this happens, you need to reauthenticate using the failed claim and repeat the action.</p>
<hr />
<h1 id="heading-how-to-authenticate-to-azuregraph-with-powershell-using-a-specific-claim">How to authenticate to Azure/Graph with PowerShell using a specific claim</h1>
<p>How to authenticate using a specific claim slightly differs based on the accessed service.</p>
<h2 id="heading-making-an-azure-connection">Making an Azure connection</h2>
<ul>
<li><p>We need to extract the claim from the <code>Invoke-AzRestMethod</code> response body</p>
</li>
<li><p>Unescape the HTML string and convert it to <code>base64</code></p>
</li>
<li><p>Use such a claim in the <code>Claims</code> parameter of the <code>Connect-AzAccount</code> to fulfill interactively auth requirements</p>
</li>
</ul>
<pre><code class="lang-powershell"><span class="hljs-comment"># invoke some request that needs extra claim (FIDO2)</span>
<span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-AzRestMethod</span> <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$restUri</span> <span class="hljs-literal">-Method</span> PUT <span class="hljs-literal">-Payload</span> (<span class="hljs-variable">$requestBody</span> | <span class="hljs-built_in">ConvertTo-Json</span> <span class="hljs-literal">-Depth</span> <span class="hljs-number">10</span>)

<span class="hljs-comment"># when PIM requires FIDO/Passkey or specific MFA, the API returns 400 with a claims challenge</span>
<span class="hljs-variable">$exception</span> = (<span class="hljs-variable">$response</span>.Content | <span class="hljs-built_in">ConvertFrom-Json</span>).error.message

<span class="hljs-comment"># reauthenticate using claim challenge</span>
<span class="hljs-keyword">if</span> (<span class="hljs-variable">$exception</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$exception</span> <span class="hljs-operator">-match</span> <span class="hljs-string">'claims=([^"]+)'</span>) {
    <span class="hljs-variable">$claimsChallenge</span> = <span class="hljs-variable">$matches</span>[<span class="hljs-number">1</span>]
    <span class="hljs-variable">$claimsChallenge</span> = [<span class="hljs-type">System.Uri</span>]::UnescapeDataString(<span class="hljs-variable">$claimsChallenge</span>)

    <span class="hljs-built_in">Write-Verbose</span> <span class="hljs-string">"Claims challenge detected: <span class="hljs-variable">$claimsChallenge</span>"</span>

    <span class="hljs-comment"># convert plaintext claim to base64</span>
    <span class="hljs-variable">$bytes</span> = [<span class="hljs-type">System.Text.Encoding</span>]::ASCII.GetBytes(<span class="hljs-variable">$claimsChallenge</span>)
    <span class="hljs-variable">$encodedClaimsChallenge</span> = [<span class="hljs-type">Convert</span>]::ToBase64String(<span class="hljs-variable">$bytes</span>)
    <span class="hljs-comment">#TIP tenant parameter is required to avoid error: WARNING: Unable to acquire token for tenant '&lt;tenantid&gt;' with error 'InteractiveBrowserCredential authentication failed: Redirect Uri mismatch.  Expected (/favicon.ico) Actual (/). '</span>
    <span class="hljs-variable">$null</span> = <span class="hljs-built_in">Connect-AzAccount</span> <span class="hljs-literal">-Claims</span> <span class="hljs-variable">$encodedClaimsChallenge</span> <span class="hljs-literal">-Tenant</span> (<span class="hljs-built_in">Get-AzContext</span>).tenant.id <span class="hljs-literal">-ErrorAction</span> Stop <span class="hljs-literal">-WarningAction</span> SilentlyContinue
}

<span class="hljs-comment"># now you can retry failed request</span>
<span class="hljs-built_in">Write-Verbose</span> <span class="hljs-string">"Retrying activation request..."</span>
<span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-AzRestMethod</span> <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$restUri</span> <span class="hljs-literal">-Method</span> PUT <span class="hljs-literal">-Payload</span> (<span class="hljs-variable">$requestBody</span> | <span class="hljs-built_in">ConvertTo-Json</span> <span class="hljs-literal">-Depth</span> <span class="hljs-number">10</span>)
</code></pre>
<h2 id="heading-making-a-graph-connection-with-default-scopes">Making a Graph connection (with default scopes)</h2>
<ul>
<li><p>It’s very similar to making just an Azure connection, with a slight difference. The claim is saved in the error message instead of the response body (when using <code>Invoke-MgGraphRequest</code>)</p>
</li>
<li><p>And you need to reauthenticate to the Graph api after new access token retrieval</p>
</li>
</ul>
<pre><code class="lang-powershell"><span class="hljs-keyword">try</span> {
    <span class="hljs-variable">$uri</span> = <span class="hljs-string">"v1.0/roleManagement/directory/roleAssignmentScheduleRequests"</span>
    <span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Method</span> POST <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$uri</span> <span class="hljs-literal">-Body</span> <span class="hljs-variable">$body</span> <span class="hljs-literal">-ErrorAction</span> Stop
} <span class="hljs-keyword">catch</span> {
    <span class="hljs-comment"># When PIM requires FIDO/Passkey or specific MFA, the API returns 403 with a claims challenge.</span>
    <span class="hljs-variable">$exception</span> = <span class="hljs-variable">$_</span>.ErrorDetails.Message

    <span class="hljs-comment"># reauthenticate using claim challenge</span>
    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$exception</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$exception</span> <span class="hljs-operator">-match</span> <span class="hljs-string">'claims=([^"]+)'</span>) {
        <span class="hljs-variable">$claimsChallenge</span> = <span class="hljs-variable">$matches</span>[<span class="hljs-number">1</span>]
        <span class="hljs-variable">$claimsChallenge</span> = [<span class="hljs-type">System.Uri</span>]::UnescapeDataString(<span class="hljs-variable">$claimsChallenge</span>)

        <span class="hljs-built_in">Write-Verbose</span> <span class="hljs-string">"Claims challenge detected: <span class="hljs-variable">$claimsChallenge</span>"</span>

        <span class="hljs-comment"># convert plaintext claim to base64</span>
        <span class="hljs-variable">$bytes</span> = [<span class="hljs-type">System.Text.Encoding</span>]::ASCII.GetBytes(<span class="hljs-variable">$claimsChallenge</span>)
        <span class="hljs-variable">$encodedClaimsChallenge</span> = [<span class="hljs-type">Convert</span>]::ToBase64String(<span class="hljs-variable">$bytes</span>)
        <span class="hljs-comment">#TIP tenant parameter is required to avoid error: WARNING: Unable to acquire token for tenant '&lt;tenantid&gt;' with error 'InteractiveBrowserCredential authentication failed: Redirect Uri mismatch.  Expected (/favicon.ico) Actual (/). '</span>
        <span class="hljs-variable">$null</span> = <span class="hljs-built_in">Connect-AzAccount</span> <span class="hljs-literal">-Claims</span> <span class="hljs-variable">$encodedClaimsChallenge</span> <span class="hljs-literal">-Tenant</span> (<span class="hljs-built_in">Get-AzContext</span>).tenant.id <span class="hljs-literal">-ErrorAction</span> Stop <span class="hljs-literal">-WarningAction</span> SilentlyContinue
    }

    <span class="hljs-comment"># Re-connect Graph SDK using the Strong Token</span>
    <span class="hljs-variable">$secureToken</span> = (<span class="hljs-built_in">Get-AzAccessToken</span> <span class="hljs-literal">-ResourceTypeName</span> MSGraph).Token
    <span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-AccessToken</span> <span class="hljs-variable">$secureToken</span> <span class="hljs-literal">-NoWelcome</span>

    <span class="hljs-built_in">Write-Verbose</span> <span class="hljs-string">"Retrying activation request..."</span>
    <span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Method</span> POST <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$uri</span> <span class="hljs-literal">-Body</span> <span class="hljs-variable">$body</span> <span class="hljs-literal">-ErrorAction</span> Stop
}
</code></pre>
<h2 id="heading-making-a-graph-connection-with-specific-scopes">Making a Graph connection (with specific scopes)</h2>
<ul>
<li><p>It again starts with the Azure reauthentication</p>
</li>
<li><p>But because we have to specify custom Graph api scopes, we cannot use <code>Connect-AzAccount</code> to retrieve the access token</p>
</li>
<li><p>Instead, we will need:</p>
<ul>
<li><p>A list of required Graph Api scopes (<code>$scope</code>)</p>
<ul>
<li>(<code>RoleAssignmentSchedule.ReadWrite.Directory</code> to activate the PIM role)</li>
</ul>
</li>
<li><p>To import the <strong>Microsoft.Identity.Client.dll</strong> library to be able to create a web request with the required scopes using <code>[Microsoft.Identity.Client.PublicClientApplicationBuilder]</code></p>
</li>
<li><p>To have a claim JSON string (<code>$claim</code>)</p>
<ul>
<li><p>can look like <code>'{"access_token":{"acrs":{"essential":true, "value":"c1"}}}'</code></p>
<ul>
<li><code>c1</code> is the ID of the authentication context defined in Azure Conditional Access policies</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>So, to authenticate using the specified claim, use <code>Get-PIMGraphTokenWithClaim</code> function from my <a target="_blank" href="https://www.powershellgallery.com/packages/AzurePIMStuff"><strong>AzurePIMStuff</strong></a> module.</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$claimsChallenge</span> = <span class="hljs-string">"&lt;claim&gt;"</span>
<span class="hljs-comment"># create new auth token</span>
<span class="hljs-variable">$secureToken</span> = <span class="hljs-built_in">Get-PIMGraphTokenWithClaim</span> <span class="hljs-literal">-Claim</span> <span class="hljs-variable">$claimsChallenge</span>
<span class="hljs-comment"># re-connect Graph SDK using the Strong Token</span>
<span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-AccessToken</span> <span class="hljs-variable">$secureToken</span> <span class="hljs-literal">-NoWelcome</span>
</code></pre>
<p>Or if you need a different kind of scopes, etc, create the request using the following code</p>
<pre><code class="lang-powershell"><span class="hljs-comment">#region prepare</span>
<span class="hljs-variable">$clientId</span> = <span class="hljs-string">"14d82eec-204b-4c2f-b7e8-296a70dab67e"</span> <span class="hljs-comment"># "Microsoft Graph PowerShell" application ID</span>
<span class="hljs-variable">$scope</span> = <span class="hljs-string">"&lt;list of required scopes&gt;"</span>
<span class="hljs-comment"># for actiovation of the PIM roles you will require "RoleAssignmentSchedule.ReadWrite.Directory"</span>
<span class="hljs-variable">$scope</span> += <span class="hljs-string">"RoleAssignmentSchedule.ReadWrite.Directory"</span>
<span class="hljs-comment"># acrs == Authentication context</span>
<span class="hljs-comment"># c1 == id of the authentication context defined in Azure Conditional Access policies</span>
<span class="hljs-variable">$claim</span> = <span class="hljs-string">'{"access_token":{"acrs":{"essential":true, "value":"c1"}}}'</span>
<span class="hljs-comment">#endregion prepare</span>

<span class="hljs-comment">#region authenticate using given claims</span>
<span class="hljs-comment"># Load Microsoft.Identity.Client.dll Assembly ([Microsoft.Identity.Client.PublicClientApplicationBuilder] type) from Az.Accounts module</span>
<span class="hljs-keyword">if</span> (!(<span class="hljs-string">"Microsoft.Identity.Client.PublicClientApplicationBuilder"</span> <span class="hljs-operator">-as</span> [<span class="hljs-type">Type</span>])) {
    <span class="hljs-comment"># to avoid dll conflicts try loaded modules first</span>
    <span class="hljs-variable">$modulePath</span> = (<span class="hljs-built_in">Get-Module</span> Az.Accounts | <span class="hljs-built_in">Sort-Object</span> Version <span class="hljs-literal">-Descending</span> | <span class="hljs-built_in">Select-Object</span> <span class="hljs-literal">-First</span> <span class="hljs-number">1</span>).ModuleBase

    <span class="hljs-keyword">if</span> (!<span class="hljs-variable">$modulePath</span>) {
        <span class="hljs-variable">$modulePath</span> = (<span class="hljs-built_in">Get-Module</span> Az.Accounts <span class="hljs-literal">-ListAvailable</span> | <span class="hljs-built_in">Sort-Object</span> Version <span class="hljs-literal">-Descending</span> | <span class="hljs-built_in">Select-Object</span> <span class="hljs-literal">-First</span> <span class="hljs-number">1</span>).ModuleBase
    }
    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$modulePath</span>) {
        <span class="hljs-variable">$dllPath</span> = <span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$modulePath</span> <span class="hljs-literal">-Filter</span> <span class="hljs-string">"Microsoft.Identity.Client.dll"</span> <span class="hljs-literal">-Recurse</span> <span class="hljs-literal">-ErrorAction</span> SilentlyContinue | <span class="hljs-built_in">Select-Object</span> <span class="hljs-literal">-First</span> <span class="hljs-number">1</span>
        <span class="hljs-keyword">if</span> (<span class="hljs-variable">$dllPath</span>) {
            <span class="hljs-built_in">Add-Type</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$dllPath</span>.FullName
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-keyword">throw</span> <span class="hljs-string">"Microsoft.Identity.Client.dll not found in Az.Accounts module."</span>
        }
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">throw</span> <span class="hljs-string">"No Az.Accounts module found. Please install the Az PowerShell module."</span>
    }
}

<span class="hljs-comment"># Build Public Client App</span>
<span class="hljs-variable">$pca</span> = [<span class="hljs-type">Microsoft.Identity.Client.PublicClientApplicationBuilder</span>]::Create(<span class="hljs-variable">$clientId</span>).WithAuthority(<span class="hljs-string">"https://login.microsoftonline.com/<span class="hljs-variable">$tenantId</span>"</span>).WithRedirectUri(<span class="hljs-string">"http://localhost"</span>).Build()

<span class="hljs-comment"># Create Interactive Request</span>
<span class="hljs-variable">$request</span> = <span class="hljs-variable">$pca</span>.AcquireTokenInteractive(<span class="hljs-variable">$scope</span>)

<span class="hljs-variable">$request</span> = <span class="hljs-variable">$request</span>.WithClaims(<span class="hljs-variable">$claim</span>)

<span class="hljs-comment"># Execute and return Token</span>
<span class="hljs-variable">$token</span> = <span class="hljs-variable">$request</span>.ExecuteAsync().GetAwaiter().GetResult().AccessToken
<span class="hljs-variable">$secureToken</span> = <span class="hljs-built_in">ConvertTo-SecureString</span> <span class="hljs-variable">$token</span> <span class="hljs-literal">-AsPlainText</span> <span class="hljs-literal">-Force</span>
<span class="hljs-comment">#endregion authenticate using given claims</span>

<span class="hljs-comment"># Re-connect Graph SDK using the Strong Token</span>
<span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-AccessToken</span> <span class="hljs-variable">$secureToken</span> <span class="hljs-literal">-NoWelcome</span>
</code></pre>
<hr />
<h1 id="heading-how-to-retrieve-the-claim">How to retrieve the claim</h1>
<p>How to retrieve the claim of the failed request depends on the used command</p>
<h2 id="heading-invoke-azrestmethod">Invoke-AzRestMethod</h2>
<ul>
<li><code>Invoke-AzRestMethod</code> stores the claim challenge in the response body</li>
</ul>
<pre><code class="lang-powershell"><span class="hljs-comment"># invoke some request that needs extra claim (FIDO2)</span>
<span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-AzRestMethod</span> <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$restUri</span> <span class="hljs-literal">-Method</span> PUT <span class="hljs-literal">-Payload</span> (<span class="hljs-variable">$requestBody</span> | <span class="hljs-built_in">ConvertTo-Json</span> <span class="hljs-literal">-Depth</span> <span class="hljs-number">10</span>)

<span class="hljs-comment"># when PIM requires FIDO/Passkey or specific MFA, the API returns 400 with a claims challenge</span>
<span class="hljs-variable">$exception</span> = (<span class="hljs-variable">$response</span>.Content | <span class="hljs-built_in">ConvertFrom-Json</span>).error.message

<span class="hljs-keyword">if</span> (<span class="hljs-variable">$exception</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$exception</span> <span class="hljs-operator">-match</span> <span class="hljs-string">'claims=([^"]+)'</span>) {
    <span class="hljs-variable">$claimsChallenge</span> = <span class="hljs-variable">$matches</span>[<span class="hljs-number">1</span>]
    <span class="hljs-variable">$claimsChallenge</span> = [<span class="hljs-type">System.Uri</span>]::UnescapeDataString(<span class="hljs-variable">$claimsChallenge</span>)

    <span class="hljs-variable">$claimsChallenge</span>
}
</code></pre>
<h2 id="heading-invoke-mggraphrequest">Invoke-MgGraphRequest</h2>
<ul>
<li><code>Invoke-MgGraphRequest</code> stores the claim challenge in the thrown error object</li>
</ul>
<pre><code class="lang-powershell"><span class="hljs-keyword">try</span> {
    <span class="hljs-variable">$uri</span> = <span class="hljs-string">"v1.0/roleManagement/directory/roleAssignmentScheduleRequests"</span>
    <span class="hljs-variable">$response</span> = <span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Method</span> POST <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$uri</span> <span class="hljs-literal">-Body</span> <span class="hljs-variable">$body</span> <span class="hljs-literal">-ErrorAction</span> Stop
} <span class="hljs-keyword">catch</span> {
    <span class="hljs-comment"># When PIM requires FIDO/Passkey or specific MFA, the API returns 403 with a claims challenge.</span>
    <span class="hljs-variable">$exception</span> = <span class="hljs-variable">$_</span>.ErrorDetails.Message

    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$exception</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$exception</span> <span class="hljs-operator">-match</span> <span class="hljs-string">'claims=([^"]+)'</span>) {
        <span class="hljs-variable">$claimsChallenge</span> = <span class="hljs-variable">$matches</span>[<span class="hljs-number">1</span>]
        <span class="hljs-variable">$claimsChallenge</span> = [<span class="hljs-type">System.Uri</span>]::UnescapeDataString(<span class="hljs-variable">$claimsChallenge</span>)

        <span class="hljs-variable">$claimsChallenge</span>
    }
}
</code></pre>
<hr />
<h1 id="heading-how-to-translate-the-authentication-context-id-to-displayname">How to translate the authentication context ID to DisplayName</h1>
<p>In my examples, I use <code>c1</code> as an authentication context ID.</p>
<p>In the Azure portal, the <strong>Authentication contexts</strong> menu doesn’t show the ID. But you can use the <strong>Developer Tools</strong> browser feature to get it. And as you can see, it belongs to the <strong>PIM_Elevation_PassKey</strong> context in my case.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765451147245/ad6c0381-c549-4788-addb-c06c1cc7dfc7.png" alt class="image--center mx-auto" /></p>
<hr />
<p>You can, of course, use the Graph api <code>beta/identity/conditionalAccess/authenticationContextClassReferences</code> to retrieve authentication contexts as well.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Uri</span> <span class="hljs-string">"beta/identity/conditionalAccess/authenticationContextClassReferences"</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765452596056/fee34d09-e0bd-413f-9dd8-ef8ffc0f0a59.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-real-world-examples">Real-world examples</h1>
<p><strong>Activating the Azure Resource PIM role (IAM)</strong></p>
<ul>
<li><a target="_blank" href="https://github.com/ztrhgf/useful_powershell_modules/blob/main/AzurePIMStuff_source/Invoke-PIMResourceRoleActivation.ps1">Invoke-PIMResourceRoleActivation</a> from my <a target="_blank" href="https://www.powershellgallery.com/packages/AzurePIMStuff"><strong>AzurePIMStuff</strong></a> module.</li>
</ul>
<p><strong>Activating the Azure Directory PIM role</strong></p>
<ul>
<li><a target="_blank" href="https://github.com/ztrhgf/useful_powershell_modules/blob/main/AzurePIMStuff_source/Invoke-PIMDirectoryRoleActivation.ps1">Invoke-PIMDirectoryRoleActivation</a> from my <a target="_blank" href="https://www.powershellgallery.com/packages/AzurePIMStuff"><strong>AzurePIMStuff</strong></a> module.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Automatic Windows VM Image update for Azure Local (HCI)]]></title><description><![CDATA[If you are using Azure Local (HCI), you might find the following PowerShell script AzureLocalVMImageUpdater.ps1 useful.
What is it good for?
The code replaces the Windows VM images in the specified Azure Local cluster(s) with the latest compatible ve...]]></description><link>https://doitpshway.com/automatic-windows-vm-image-update-for-azure-local-hci</link><guid isPermaLink="true">https://doitpshway.com/automatic-windows-vm-image-update-for-azure-local-hci</guid><category><![CDATA[Powershell]]></category><category><![CDATA[automation]]></category><category><![CDATA[HCI]]></category><category><![CDATA[Azure Local]]></category><category><![CDATA[update ]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Mon, 27 Oct 2025 13:46:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761572739089/371d5b66-2039-4cd5-974b-aaf89653af26.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you are using Azure Local (HCI), you might find the following PowerShell script <a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/AzureLocalVMImageUpdater.ps1">AzureLocalVMImageUpdater.ps</a><a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/AzureLocalVMImageUpdater.ps1">1</a> useful.</p>
<p><a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/AzureLocalVMImageUpdater.ps1"><strong>What is it good for</strong></a><strong>?</strong></p>
<p>The code replaces the Windows VM images in the specified Azure Local cluster(s) with the latest compatible version available in the Marketplace. So whenever you create a new VM, you will use the updated OS version.</p>
<hr />
<h1 id="heading-requirements">Requirements</h1>
<p><strong>Image name</strong></p>
<ul>
<li>Image must have a suffix <code>-&lt;number&gt;</code> (i.e. Windows2025-01), so if the image gets replaced with the new one, the <code>&lt;number&gt;</code> can be increased to emphasize the change</li>
</ul>
<p><strong>Permissions</strong></p>
<ul>
<li><p>IAM (RBAC) assignments for the subscription where the HCI cluster is located</p>
<ul>
<li><p><code>Azure Stack HCI Administrator</code> - to be able to manage Azure Local VM Images</p>
</li>
<li><p><code>Reader</code> - to be able to query cluster health, VM Images, and other required resources</p>
</li>
</ul>
</li>
</ul>
<p><strong>PowerShell</strong></p>
<ul>
<li>Core (to be able to use parallel processing)</li>
</ul>
<p><strong>PowerShell modules</strong></p>
<ul>
<li><p>Az (Az.Accounts, Az.ResourceGraph)</p>
</li>
<li><p>Azure Cli</p>
</li>
<li><p>AzureLocalStuff</p>
</li>
</ul>
<hr />
<h1 id="heading-how-to-use-it">How to use it?</h1>
<ul>
<li><p>Create Azure Automation Runbook</p>
</li>
<li><p>Add required PowerShell modules</p>
</li>
<li><p>Grant required IAM role assignments to the Automation Account Managed Identity</p>
</li>
<li><p>Customize the <code>$azureLocalClusterList</code> variable to match your environment in the <a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/AzureLocalVMImageUpdater.ps1">AzureLocalVMImageUpdater.ps</a><a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/AzureLocalVMImageUpdater.ps1">1</a> script</p>
<p>  <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761572302040/c7b5f839-34d6-4457-9d73-f1f5ca1f875d.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>Use modified code in your Runbook</p>
</li>
<li><p>Set the Runbook schedule and watch the magic :)</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Fixing Autopilot devices' hash-mismatch  issues using Intune on-demand remediations]]></title><description><![CDATA[Problem
It happens that the hardware hash of your Autopilot device gets changed. Thanks to the replacement of the motherboard or some other issue. This can lead to future problems when your users need to reinstall the operating system, but the Autopi...]]></description><link>https://doitpshway.com/fixing-autopilot-devices-hash-mismatch-issues-using-intune-on-demand-remediations</link><guid isPermaLink="true">https://doitpshway.com/fixing-autopilot-devices-hash-mismatch-issues-using-intune-on-demand-remediations</guid><category><![CDATA[intune]]></category><category><![CDATA[autopilot]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Fri, 24 Oct 2025 14:16:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761311371403/6f1a4084-12c9-4cf8-8f9c-1927096b5dd4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-problem">Problem</h1>
<p>It happens that the hardware hash of your Autopilot device gets changed. Thanks to the replacement of the motherboard or some other issue. This can lead to future problems when your users need to reinstall the operating system, but the Autopilot process doesn’t kick in because of a hash mismatch.</p>
<p>You can retrieve Autopilot devices with a hash mismatch using the following PowerShell code:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Connect-MgGraph</span>

<span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Uri</span> <span class="hljs-string">"beta/deviceManagement/windowsAutopilotDeviceIdentities"</span> | 
<span class="hljs-built_in">Get-MgGraphAllPages</span> | ? RemediationState <span class="hljs-operator">-NE</span> <span class="hljs-string">'noRemediationRequired'</span> | 
<span class="hljs-built_in">select</span> DisplayName,SerialNumber,RemediationState
</code></pre>
<p>And the result can look like this</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761299455331/0556bd36-c7ec-45fd-abc9-b3f78ab22ddb.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><code>AutomaticRemediationRequired</code> means Intune should automatically remediate this issue (you can ignore it)</p>
</li>
<li><p><code>ManualRemediationRequired</code> and <code>Unknown</code> mean you have to solve this on your own</p>
</li>
</ul>
<p>In the Intune portal, you can see the issues in <code>Profile status</code> column like below</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761298507557/c1701b70-cdb7-48b8-9f5c-c8cb75a95794.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761311731373/3f2c29e1-bff9-4889-ba67-86bdead5164b.png" alt class="image--center mx-auto" /></p>
<p><strong>The problem is that the change of the autopilot hash is not possible for already enrolled devices</strong> 😕</p>
<hr />
<h1 id="heading-solution">Solution</h1>
<p>So, how to fix the autopilot hash mismatch?</p>
<p>By creating <strong>automation that gathers the current hash for problematic devices using an on-demand remediation script</strong>. This way, you can easily import the retrieved hash when the device needs to be reset without bothering your users.</p>
<p>If you implement my solution, you will see new on-demand remediations, like in the screenshot below, named in form <code>_invCmd_&lt;datetime&gt;_&lt;serialNumber&gt;_getAutopilotHash</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761298252931/9cbefb49-33fd-4a49-8301-020cc6f04e94.png" alt class="image--center mx-auto" /></p>
<p>And using the following code, you will be able to get the remediation result (hash string) and import it to the Autopilot database</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$hash</span> = <span class="hljs-built_in">Get-IntuneRemediationResult</span> | <span class="hljs-built_in">select</span> <span class="hljs-literal">-ExpandProperty</span> ProcessedOutput <span class="hljs-comment"># choose the correct remediation (based on device serialNumber)</span>

Upload<span class="hljs-literal">-IntuneAutopilotHash</span> <span class="hljs-literal">-psObject</span> <span class="hljs-variable">$hash</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761314277714/d6ca8b83-b5c3-41ab-8026-58eb7441eecc.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Option to use Intune on-demand remediations</p>
</li>
<li><p>Option to create an Azure Automation or an Enterprise Application that will be used in an on-prem scheduled task</p>
</li>
<li><p>Automation account Graph api permissions</p>
<ul>
<li><p>DeviceManagementManagedDevices.Read.All</p>
</li>
<li><p>DeviceManagementManagedDevices.PrivilegedOperations.All</p>
</li>
<li><p>DeviceManagementServiceConfig.Read.All</p>
</li>
<li><p>DeviceManagementConfiguration.ReadWrite.All</p>
</li>
<li><p>DeviceManagementScripts.ReadWrite.All</p>
</li>
</ul>
</li>
<li><p>Automation code PowerShell modules</p>
<ul>
<li><p>Microsoft.Graph.Authentication</p>
</li>
<li><p>Microsoft.Graph.DeviceManagement</p>
</li>
<li><p>Microsoft.Graph.Beta.DeviceManagement</p>
</li>
<li><p>CommonStuff</p>
</li>
<li><p>MSGraphStuff</p>
</li>
<li><p>IntuneStuff</p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-how-to">How to</h2>
<p>In automation of your choice (Azure Automation or on-prem scheduled task), run the PowerShell script <a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/autopilotHashFix.ps1">autopilotHashFix.ps1</a></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The script is meant to be run using Azure Automation managed identity, but you can rewrite the authentication part to use an Azure Enterprise application instead (<code>$null = Connect-MgGraph -TenantId $_yourTenantDomain -ClientSecretCredential $SPCred</code>)</div>
</div>

<p>Don’t forget to <strong>grant all Graph Api application permissions</strong> mentioned in the Prerequisites step, and also <strong>install all required PowerShell modules</strong>!</p>
<hr />
<h1 id="heading-summary">Summary</h1>
<p>Use Intune on-demand remediations to get Autopilot devices hash for all devices, where the uploaded hash doesn’t match the current one.</p>
<p>This way, you will be ready to import the correct one when the OS reinstall needs to be done without any employee interaction.</p>
]]></content:encoded></item><item><title><![CDATA[Supercharge Your Azure API Calls: Master Azure Resource Manager batching with PowerShell]]></title><description><![CDATA[Introduction & Problem Statement
Picture this: You're tasked with auditing 200 virtual machines across multiple Azure subscriptions. Your PowerShell script needs to check VM status, retrieve configurations, and gather diagnostic information. Using th...]]></description><link>https://doitpshway.com/supercharge-your-azure-api-calls-master-azure-resource-manager-batching-with-powershell</link><guid isPermaLink="true">https://doitpshway.com/supercharge-your-azure-api-calls-master-azure-resource-manager-batching-with-powershell</guid><category><![CDATA[Azure]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[Batch Processing]]></category><category><![CDATA[performance]]></category><category><![CDATA[Azure Resource Manager]]></category><category><![CDATA[APIs]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Fri, 01 Aug 2025 13:34:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1754054352985/8ab91edd-76e3-43cf-99cf-f637bbacaf18.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2 id="heading-introduction-amp-problem-statement">Introduction &amp; Problem Statement</h2>
<p>Picture this: You're tasked with auditing 200 virtual machines across multiple Azure subscriptions. Your PowerShell script needs to check VM status, retrieve configurations, and gather diagnostic information. Using the traditional approach, this means making 600+ individual API calls to Azure Resource Manager (ARM) - three calls per VM.</p>
<p>The result? Your script crawls along for 30+ minutes, constantly hits throttling limits, and occasionally fails with timeout errors. Meanwhile, your manager is asking why the monthly audit report is always late, and you're spending more time babysitting scripts than actually analyzing the data.</p>
<p><strong>What if I told you the same audit could be completed in under 3 minutes with just 30 API calls?</strong></p>
<p>That's the power of Azure Resource Manager API batching. A lesser-known but incredibly powerful Azure capability that can reduce your API calls by up to 80%. In this guide, we'll explore two PowerShell functions that make this possible: <code>New-AzureBatchRequest</code> and <code>Invoke-AzureBatchRequest</code> hosted in the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureCommonStuff/1.0.7">AzureCommonStuff</a> module.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There is a similar <a target="_self" href="https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts">batching feature for the Microsoft Graph Api</a> 😎</div>
</div>

<hr />
<h2 id="heading-understanding-the-challenge">Understanding the Challenge</h2>
<h3 id="heading-why-individual-api-calls-dont-scale">Why Individual API Calls Don't Scale</h3>
<p>Azure Resource Manager implements rate limiting to protect the service infrastructure. The current limits are:</p>
<ul>
<li><p><strong>Read operations</strong>: 12,000 requests per hour per subscription</p>
</li>
<li><p><strong>Write operations</strong>: 1,200 requests per hour per subscription</p>
</li>
</ul>
<p>While these numbers might seem generous, they disappear quickly in automation scenarios. Consider a simple VM inventory script:</p>
<ul>
<li><p>100 VMs × 3 API calls each = 300 requests</p>
</li>
<li><p>That's 2.5% of your hourly read quota in a single script run</p>
</li>
</ul>
<h3 id="heading-the-performance-tax">The Performance Tax</h3>
<p>Every individual API call carries overhead:</p>
<ul>
<li><p><strong>Network latency</strong>: 50-200ms per request, depending on your location</p>
</li>
<li><p><strong>Authentication processing</strong>: Token validation for each request</p>
</li>
<li><p><strong>Connection overhead</strong>: TCP handshake and SSL negotiation</p>
</li>
<li><p><strong>JSON processing</strong>: Multiple small payloads instead of one optimized batch</p>
</li>
</ul>
<h3 id="heading-common-workarounds-and-their-limitations">Common Workarounds and Their Limitations</h3>
<p>Most PowerShell developers try these approaches:</p>
<ol>
<li><p><strong>Sequential processing with delays</strong> - Slow and still hits limits</p>
</li>
<li><p><strong>Parallel processing with</strong> <code>ForEach-Object -Parallel</code> - Actually makes throttling worse</p>
</li>
<li><p><strong>Custom retry logic</strong> - Adds complexity but doesn't solve the root problem</p>
</li>
</ol>
<p>These approaches treat the symptoms, not the cause. We need a fundamentally different approach.</p>
<h3 id="heading-the-hidden-solution">The Hidden Solution</h3>
<p>Azure Resource Manager supports an undocumented batch API endpoint that combines up to 20 individual requests into a single HTTP call. This isn't in the official documentation, but it's the same mechanism the Azure portal uses behind the scenes (as shown in the image below 👇). It's stable, reliable, and incredibly powerful once you know how to use it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754050702149/5aa64515-4704-486b-8546-310131f82175.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-solution-overview">Solution Overview</h2>
<h3 id="heading-how-batching-works">How Batching Works</h3>
<p>Instead of making individual API calls, we package multiple requests into a single batch operation. Azure processes these requests concurrently on the server side and returns all results in one response.</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Traditional approach (slow)</span>
<span class="hljs-variable">$vmData</span> = <span class="hljs-selector-tag">@</span>()
<span class="hljs-keyword">foreach</span> (<span class="hljs-variable">$vmName</span> <span class="hljs-keyword">in</span> <span class="hljs-variable">$vmNames</span>) {
    <span class="hljs-variable">$vm</span> = <span class="hljs-built_in">Get-AzVM</span> <span class="hljs-literal">-ResourceGroupName</span> <span class="hljs-variable">$rgName</span> <span class="hljs-literal">-Name</span> <span class="hljs-variable">$vmName</span>
    <span class="hljs-variable">$vmData</span> += <span class="hljs-variable">$vm</span>
}
<span class="hljs-comment"># Result: 100 VMs = 100 API calls</span>

<span class="hljs-comment"># Batching approach (fast)</span>
<span class="hljs-variable">$batchRequests</span> = <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/resourceGroups/<span class="hljs-variable">$rgName</span>/providers/Microsoft.Compute/virtualMachines/&lt;placeholder&gt;?api-version=2023-07-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$vmNames</span>
<span class="hljs-variable">$vmData</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$batchRequests</span>
<span class="hljs-comment"># Result: 100 VMs = 5 API calls (batches of 20)</span>
</code></pre>
<h3 id="heading-key-concepts">Key Concepts</h3>
<p><strong>Batch Request Objects</strong>: Each request contains a method (GET, POST, etc.), URL, and optional headers. The batch system processes these concurrently.</p>
<p><strong>Placeholder Pattern</strong>: When you need similar requests with different IDs, the placeholder system generates requests automatically without manual loops.</p>
<p><strong>Error Isolation</strong>: If one request in a batch fails, the others continue processing. Each response includes its own status code and error details.</p>
<hr />
<h2 id="heading-how-it-works">How It Works</h2>
<h3 id="heading-new-azurebatchrequest">New-AzureBatchRequest</h3>
<p>Function <code>New-AzureBatchRequest</code> creates standardized request objects that can be batched together:</p>
<ul>
<li><p><strong>URL Flexibility</strong>: You can use absolute URLs (<a target="_blank" href="https://management.azure.com/"><code>https://management.azure.com/</code></a><code>...</code>) or relative URLs (<code>/subscriptions/...</code>). The function handles both seamlessly.</p>
</li>
<li><p><strong>Placeholder Magic</strong>: The real power comes from the placeholder parameter. Instead of writing loops, you specify a URL template with <code>&lt;placeholder&gt;</code> and provide an array of values:</p>
</li>
</ul>
<pre><code class="lang-powershell"><span class="hljs-comment"># Instead of this manual loop:</span>
<span class="hljs-variable">$requests</span> = <span class="hljs-selector-tag">@</span>()
<span class="hljs-keyword">foreach</span> (<span class="hljs-variable">$subscriptionId</span> <span class="hljs-keyword">in</span> <span class="hljs-variable">$subscriptionIds</span>) {
    <span class="hljs-variable">$requests</span> += <span class="hljs-selector-tag">@</span>{
        Name = <span class="hljs-string">"sub_<span class="hljs-variable">$subscriptionId</span>"</span>
        HttpMethod = <span class="hljs-string">"GET"</span>
        URL = <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span>
    }
}

<span class="hljs-comment"># Use this elegant approach:</span>
<span class="hljs-variable">$requests</span> = <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/&lt;placeholder&gt;/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$subscriptionIds</span>
</code></pre>
<h3 id="heading-invoke-azurebatchrequest">Invoke-AzureBatchRequest</h3>
<p>Function <code>Invoke-AzureBatchRequest</code> handles the complex orchestration of sending batches and processing responses:</p>
<ul>
<li><p><strong>Automatic Chunking</strong>: The function automatically splits your requests into chunks of 20 (Azure's limit) and processes them sequentially.</p>
</li>
<li><p><strong>Response Beautification</strong>: By default, the function extracts just the data you need from Azure's verbose response format, making results much easier to work with.</p>
</li>
</ul>
<h3 id="heading-step-by-step-walkthrough">Step-by-Step Walkthrough</h3>
<p>Let's trace through a complete batching operation:</p>
<h4 id="heading-step-1-create-batch-requests">Step 1: Create Batch Requests</h4>
<pre><code class="lang-powershell"><span class="hljs-comment"># Get VM status across multiple resource groups</span>
<span class="hljs-variable">$resourceGroups</span> = <span class="hljs-selector-tag">@</span>(<span class="hljs-string">'rg-web-servers'</span>, <span class="hljs-string">'rg-database-servers'</span>, <span class="hljs-string">'rg-cache-servers'</span>)
<span class="hljs-variable">$subscriptionId</span> = (<span class="hljs-built_in">Get-AzContext</span>).Subscription.Id

<span class="hljs-variable">$requests</span> = <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/resourceGroups/&lt;placeholder&gt;/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$resourceGroups</span>
</code></pre>
<p>Behind the scenes, this creates three request objects:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Generated request objects (simplified)</span>
<span class="hljs-selector-tag">@</span>(
    <span class="hljs-selector-tag">@</span>{ Name = <span class="hljs-string">"123456"</span>; HttpMethod = <span class="hljs-string">"GET"</span>; URL = <span class="hljs-string">"/subscriptions/abc.../resourceGroups/rg-web-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> },
    <span class="hljs-selector-tag">@</span>{ Name = <span class="hljs-string">"789012"</span>; HttpMethod = <span class="hljs-string">"GET"</span>; URL = <span class="hljs-string">"/subscriptions/abc.../resourceGroups/rg-database-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> },
    <span class="hljs-selector-tag">@</span>{ Name = <span class="hljs-string">"345678"</span>; HttpMethod = <span class="hljs-string">"GET"</span>; URL = <span class="hljs-string">"/subscriptions/abc.../resourceGroups/rg-cache-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> }
)
</code></pre>
<h4 id="heading-step-2-execute-the-batch">Step 2: Execute the Batch</h4>
<pre><code class="lang-powershell"><span class="hljs-variable">$allVMs</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$requests</span>
</code></pre>
<p>The function:</p>
<ol>
<li><p><strong>Validates</strong> all URLs and request objects</p>
</li>
<li><p><strong>Chunks</strong> requests into groups of 20</p>
</li>
<li><p><strong>Sends</strong> each chunk as a single HTTP POST to Azure's batch endpoint</p>
</li>
<li><p><strong>Processes</strong> responses and handles errors</p>
</li>
<li><p><strong>Beautifies</strong> results by extracting just the VM data</p>
</li>
</ol>
<h4 id="heading-step-3-work-with-results">Step 3: Work with Results</h4>
<pre><code class="lang-powershell"><span class="hljs-comment"># Results are ready to use immediately</span>
<span class="hljs-variable">$windowsVMs</span> = <span class="hljs-variable">$allVMs</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.storageProfile.osDisk.osType <span class="hljs-operator">-eq</span> <span class="hljs-string">'Windows'</span> }
<span class="hljs-variable">$runningVMs</span> = <span class="hljs-variable">$allVMs</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.powerState <span class="hljs-operator">-eq</span> <span class="hljs-string">'VM running'</span> }

<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"Found <span class="hljs-variable">$</span>(<span class="hljs-variable">$allVMs</span>.Count) total VMs, <span class="hljs-variable">$</span>(<span class="hljs-variable">$windowsVMs</span>.Count) Windows VMs, <span class="hljs-variable">$</span>(<span class="hljs-variable">$runningVMs</span>.Count) running"</span>
</code></pre>
<hr />
<h2 id="heading-practical-examples">Practical Examples</h2>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The <a target="_self" href="https://www.powershellgallery.com/packages/AzurePIMStuff">AzurePIMStuff</a> module includes functions that utilize batching under the hood; feel free to take inspiration from them.</div>
</div>

<h3 id="heading-real-world-scenario-security-compliance-audit">Real-World Scenario: Security Compliance Audit</h3>
<p>Here's a more complex example that demonstrates the power of batching for compliance scenarios:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Scenario: Audit network security groups, storage accounts, and key vaults across subscriptions</span>
<span class="hljs-variable">$subscriptionIds</span> = (<span class="hljs-built_in">Get-AzSubscription</span> | <span class="hljs-built_in">Where-Object</span> State <span class="hljs-operator">-eq</span> <span class="hljs-string">'Enabled'</span>).Id

<span class="hljs-comment"># Build audit requests for multiple resource types</span>
<span class="hljs-variable">$auditRequests</span> = <span class="hljs-selector-tag">@</span>()

<span class="hljs-comment"># Network Security Groups</span>
<span class="hljs-variable">$auditRequests</span> += <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/&lt;placeholder&gt;/providers/Microsoft.Network/networkSecurityGroups?api-version=2024-03-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$subscriptionIds</span> <span class="hljs-literal">-name</span> <span class="hljs-string">"NSGs"</span>

<span class="hljs-comment"># Storage Accounts  </span>
<span class="hljs-variable">$auditRequests</span> += <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/&lt;placeholder&gt;/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$subscriptionIds</span> <span class="hljs-literal">-name</span> <span class="hljs-string">"Storage"</span>

<span class="hljs-comment"># Key Vaults</span>
<span class="hljs-variable">$auditRequests</span> += <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/&lt;placeholder&gt;/providers/Microsoft.KeyVault/vaults?api-version=2024-11-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$subscriptionIds</span> <span class="hljs-literal">-name</span> <span class="hljs-string">"KeyVaults"</span>

<span class="hljs-comment"># Execute all requests efficiently</span>
<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"Starting audit of <span class="hljs-variable">$</span>(<span class="hljs-variable">$subscriptionIds</span>.Count) subscriptions..."</span>
<span class="hljs-variable">$auditResults</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$auditRequests</span>

<span class="hljs-comment"># Process results by resource type</span>
<span class="hljs-variable">$nsgs</span> = <span class="hljs-variable">$auditResults</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.RequestName <span class="hljs-operator">-like</span> <span class="hljs-string">"NSGs_*"</span> }
<span class="hljs-variable">$storageAccounts</span> = <span class="hljs-variable">$auditResults</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.RequestName <span class="hljs-operator">-like</span> <span class="hljs-string">"Storage_*"</span> }
<span class="hljs-variable">$keyVaults</span> = <span class="hljs-variable">$auditResults</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.RequestName <span class="hljs-operator">-like</span> <span class="hljs-string">"KeyVaults_*"</span> }

<span class="hljs-comment"># Generate compliance report</span>
<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"Audit Summary:"</span>
<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"- Network Security Groups: <span class="hljs-variable">$</span>(<span class="hljs-variable">$nsgs</span>.Count)"</span>
<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"- Storage Accounts: <span class="hljs-variable">$</span>(<span class="hljs-variable">$storageAccounts</span>.Count)"</span>  
<span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"- Key Vaults: <span class="hljs-variable">$</span>(<span class="hljs-variable">$keyVaults</span>.Count)"</span>

<span class="hljs-comment"># Check for common security issues</span>
<span class="hljs-variable">$publicStorageAccounts</span> = <span class="hljs-variable">$storageAccounts</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.properties.allowBlobPublicAccess <span class="hljs-operator">-eq</span> <span class="hljs-variable">$true</span> }
<span class="hljs-keyword">if</span> (<span class="hljs-variable">$publicStorageAccounts</span>) {
    <span class="hljs-built_in">Write-Warning</span> <span class="hljs-string">"Found <span class="hljs-variable">$</span>(<span class="hljs-variable">$publicStorageAccounts</span>.Count) storage accounts with public blob access enabled"</span>
}
</code></pre>
<p><strong>Performance comparison for this scenario:</strong></p>
<ul>
<li><p><strong>Traditional approach</strong>: 600+ API calls, 15-20 minutes</p>
</li>
<li><p><strong>Batching approach</strong>: 30 API calls, 2-3 minutes</p>
</li>
</ul>
<h3 id="heading-combining-different-http-methods">Combining different HTTP Methods</h3>
<p>While most scenarios use GET requests, you can batch other operations too:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Batch different types of operations</span>
<span class="hljs-variable">$mixedRequests</span> = <span class="hljs-selector-tag">@</span>()

<span class="hljs-comment"># GET requests for current state</span>
<span class="hljs-variable">$mixedRequests</span> += <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/resourceGroups/&lt;placeholder&gt;/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$resourceGroups</span> <span class="hljs-literal">-name</span> <span class="hljs-string">"GetVMs"</span>

<span class="hljs-comment"># POST requests for operations (example: restart VMs)</span>
<span class="hljs-variable">$vmIds</span> = <span class="hljs-selector-tag">@</span>(<span class="hljs-string">'vm1'</span>, <span class="hljs-string">'vm2'</span>, <span class="hljs-string">'vm3'</span>)
<span class="hljs-variable">$mixedRequests</span> += <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/resourceGroups/<span class="hljs-variable">$rgName</span>/providers/Microsoft.Compute/virtualMachines/&lt;placeholder&gt;/restart?api-version=2024-11-01"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$vmIds</span> <span class="hljs-literal">-method</span> <span class="hljs-string">"POST"</span> <span class="hljs-literal">-name</span> <span class="hljs-string">"RestartVMs"</span>

<span class="hljs-comment"># Execute mixed batch</span>
<span class="hljs-variable">$results</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$mixedRequests</span>

<span class="hljs-comment"># Check operation results</span>
<span class="hljs-variable">$getResults</span> = <span class="hljs-variable">$results</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.RequestName <span class="hljs-operator">-like</span> <span class="hljs-string">"GetVMs_*"</span> }
<span class="hljs-variable">$restartResults</span> = <span class="hljs-variable">$results</span> | <span class="hljs-built_in">Where-Object</span> { <span class="hljs-variable">$_</span>.RequestName <span class="hljs-operator">-like</span> <span class="hljs-string">"RestartVMs_*"</span> }
</code></pre>
<h3 id="heading-multiple-kql-queries">Multiple KQL queries</h3>
<pre><code class="lang-powershell">[<span class="hljs-type">System.Collections.Generic.List</span>[<span class="hljs-type">object</span>]] <span class="hljs-variable">$batchRequest</span> = <span class="hljs-selector-tag">@</span>()

<span class="hljs-variable">$queryUrl</span> = <span class="hljs-string">"https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01"</span>

<span class="hljs-variable">$diskQuery</span> = <span class="hljs-string">@"
ExtensibilityResources
    | where type =~ "microsoft.azurestackhci/virtualmachineinstances"
"@</span>

<span class="hljs-variable">$batchRequest</span>.add((<span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-method</span> POST <span class="hljs-literal">-url</span> <span class="hljs-variable">$queryUrl</span> <span class="hljs-literal">-content</span> <span class="hljs-selector-tag">@</span>{ query = <span class="hljs-variable">$diskQuery</span> } <span class="hljs-literal">-name</span> <span class="hljs-string">"diskInfo"</span> ))

<span class="hljs-variable">$nicQuery</span> = <span class="hljs-string">@"
resources
| where type =~ "Microsoft.AzureStackHCI/networkinterfaces" and
    properties.provisioningState =~ "succeeded"
"@</span>

<span class="hljs-variable">$batchRequest</span>.add((<span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-method</span> POST <span class="hljs-literal">-url</span> <span class="hljs-variable">$queryUrl</span> <span class="hljs-literal">-content</span> <span class="hljs-selector-tag">@</span>{ query = <span class="hljs-variable">$nicQuery</span> } <span class="hljs-literal">-name</span> <span class="hljs-string">"nicInfo"</span>))

<span class="hljs-comment"># Invoking two KQL queries in a batch to get Azure Stack HCI VM disk and NIC information</span>
<span class="hljs-variable">$batchResult</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$batchRequest</span>

<span class="hljs-variable">$vmListDiskInfo</span> = <span class="hljs-variable">$batchResult</span> | ? RequestName <span class="hljs-operator">-EQ</span> <span class="hljs-string">"diskInfo"</span>
<span class="hljs-variable">$vmListNicInfo</span> = <span class="hljs-variable">$batchResult</span> | ? RequestName <span class="hljs-operator">-EQ</span> <span class="hljs-string">"nicInfo"</span>
</code></pre>
<h3 id="heading-troubleshooting-tips">Troubleshooting Tips</h3>
<p>When things don't work as expected, try these approaches:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Enable verbose output to see what's happening</span>
<span class="hljs-variable">$VerbosePreference</span> = <span class="hljs-string">'Continue'</span>
<span class="hljs-variable">$results</span> = <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$requests</span> <span class="hljs-literal">-Verbose</span>

<span class="hljs-comment"># Check for failed requests by examining raw responses</span>
<span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$requests</span> <span class="hljs-literal">-dontBeautifyResult</span>

<span class="hljs-comment"># Test individual URLs before batching</span>
<span class="hljs-variable">$testUrl</span> = <span class="hljs-string">"/subscriptions/<span class="hljs-variable">$subscriptionId</span>/resourceGroups/<span class="hljs-variable">$rgName</span>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span>
<span class="hljs-keyword">try</span> {
    <span class="hljs-variable">$testResult</span> = <span class="hljs-built_in">Invoke-AzRestMethod</span> <span class="hljs-literal">-Uri</span> <span class="hljs-string">"https://management.azure.com<span class="hljs-variable">$testUrl</span>"</span> <span class="hljs-literal">-Method</span> GET
    <span class="hljs-built_in">Write-Host</span> <span class="hljs-string">"Test successful: <span class="hljs-variable">$</span>(<span class="hljs-variable">$testResult</span>.StatusCode)"</span>
} <span class="hljs-keyword">catch</span> {
    <span class="hljs-built_in">Write-Error</span> <span class="hljs-string">"URL test failed: <span class="hljs-variable">$_</span>"</span>
}
</code></pre>
<hr />
<h2 id="heading-tips-and-considerations">Tips and Considerations</h2>
<h3 id="heading-performance-optimization">Performance Optimization</h3>
<p><strong>Use Resource Graph Query:</strong> If possible, gather your data from the <a target="_blank" href="https://portal.azure.com/#view/HubsExtension/ArgQueryBlade">Resource Graph Table</a>, which will always be faster than api calls. Nice <a target="_blank" href="https://www.jasonfritts.me/2024/08/22/export-all-azure-role-assignments-using-azure-resource-graph/">example of getting IAM assignments</a>.</p>
<p><strong>Monitor Your Quotas</strong>: Even with batching, be mindful of overall API limits across your entire environment. Use <code>Get-AzConsumptionUsageDetail</code> to monitor usage patterns.</p>
<h3 id="heading-common-mistakes-to-avoid">Common Mistakes to Avoid</h3>
<p><strong>URL Template Errors</strong>: Make sure your placeholder strings don't conflict with actual URL parameters:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Wrong - conflicts with OData filter syntax</span>
<span class="hljs-variable">$badUrl</span> = <span class="hljs-string">"/virtualMachines?<span class="hljs-variable">$filter</span>=name eq '&lt;placeholder&gt;'"</span>

<span class="hljs-comment"># Right - placeholder in path segment</span>
<span class="hljs-variable">$goodUrl</span> = <span class="hljs-string">"/virtualMachines/&lt;placeholder&gt;"</span>
</code></pre>
<p><strong>Authentication Scope Issues</strong>: Ensure your PowerShell session has permissions for all resources you're querying. Cross-subscription batching requires appropriate access.</p>
<p><strong>Api version errors:</strong> Ensure you append a valid api version (<code>api-version=2023-07-01</code> for example) to each URL request!</p>
<pre><code class="lang-powershell"><span class="hljs-string">".../resourceGroups/<span class="hljs-variable">$rgName</span>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"</span>
</code></pre>
<p>If you are unsure what api to use:</p>
<ul>
<li><p>Check <a target="_blank" href="https://learn.microsoft.com/en-us/rest/api/compute/virtual-machines/restart?view=rest-compute-2025-02-01&amp;tabs=HTTP">official documentation</a>.</p>
</li>
<li><p>Use a random one, and when the request fails with a 400 error, check the error message for the list of correct api versions.</p>
</li>
<li><p>Use the official corresponding Az cmdlet with <code>-debug</code> parameter (<code>Get-AzStorageAccount -debug</code>) and check the '<code>Absolute uri</code>' output.</p>
</li>
<li><p>Use developer tools (F12) in your browser when using the Azure Portal and check the request URL there.</p>
</li>
</ul>
<hr />
<h2 id="heading-getting-started">Getting Started</h2>
<p>These functions are part of the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureCommonStuff"><strong>AzureCommonStuff</strong></a> module on PowerShell Gallery. Here's how to install it:</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># Install the module (one-time setup)</span>
<span class="hljs-built_in">Install-Module</span> AzureCommonStuff

<span class="hljs-comment"># Import the module in your session</span>
<span class="hljs-built_in">Import-Module</span> AzureCommonStuff

<span class="hljs-comment"># Verify the functions are available</span>
<span class="hljs-built_in">Get-Command</span> <span class="hljs-built_in">New-AzureBatchRequest</span>, <span class="hljs-built_in">Invoke-AzureBatchRequest</span>

<span class="hljs-comment"># Get help</span>
<span class="hljs-built_in">Get-Help</span> <span class="hljs-built_in">New-AzureBatchRequest</span> <span class="hljs-literal">-Examples</span>
<span class="hljs-built_in">Get-Help</span> <span class="hljs-built_in">Invoke-AzureBatchRequest</span> <span class="hljs-literal">-Examples</span>
</code></pre>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before using these functions, make sure you have:</p>
<ul>
<li><p>PowerShell 5.1 or PowerShell 7+</p>
</li>
<li><p>Az PowerShell module installed (<code>Install-Module Az</code>)</p>
</li>
<li><p>Authenticated Azure session (<code>Connect-AzAccount</code>)</p>
</li>
<li><p>Appropriate permissions for the resources you want to access</p>
</li>
</ul>
<hr />
<h1 id="heading-summary">Summary</h1>
<p>Azure Resource Manager API batching transforms the way you interact with Azure at scale. By reducing API calls and execution times, these PowerShell functions unlock new possibilities for automation, reporting, and compliance scenarios.</p>
<p>The functions <code>New-AzureBatchRequest</code> and <code>Invoke-AzureBatchRequest</code> leverage an undocumented but stable Azure API that's used by the Azure portal itself. While it's not officially documented, it's proven reliable across multiple Azure updates and represents a significant competitive advantage for anyone doing serious Azure automation work.</p>
<p>Start experimenting with small batches, measure the performance improvements, and gradually adopt batching across your Azure automation portfolio. Your scripts will run faster, your users will be happier, and you'll spend less time waiting for operations to complete and more time solving interesting problems.</p>
]]></content:encoded></item><item><title><![CDATA[Exporting BitLocker, LAPS, and FileVault Keys from Intune using Azure DevOps pipeline]]></title><description><![CDATA[Keeping a secure, version-controlled backup of your Intune-managed device data, including BitLocker, LAPS, and FileVault keys, is a best practice for any modern IT team. In this post, I’ll guide you through an Azure DevOps pipeline that automates the...]]></description><link>https://doitpshway.com/exporting-bitlocker-laps-and-filevault-keys-from-intune-to-git-using-azure-devops-pipeline</link><guid isPermaLink="true">https://doitpshway.com/exporting-bitlocker-laps-and-filevault-keys-from-intune-to-git-using-azure-devops-pipeline</guid><category><![CDATA[Powershell]]></category><category><![CDATA[intune]]></category><category><![CDATA[Backup]]></category><category><![CDATA[configuration]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Pipeline]]></category><category><![CDATA[Entra ID]]></category><category><![CDATA[Azure]]></category><category><![CDATA[azure-devops]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Mon, 28 Jul 2025 09:04:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1754039626907/fa20e18a-a1e6-4b89-8c98-3f0802905a37.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Keeping a secure, version-controlled backup of your Intune-managed device data, including <strong>BitLocker</strong>, <strong>LAPS</strong>, and <strong>FileVault</strong> keys, is a best practice for any modern IT team. In this post, I’ll guide you through an Azure DevOps pipeline that automates the daily export of device security data from Microsoft Intune and Azure AD, validates it, and commits the data to your private Azure DevOps repository.</p>
<p>Meet the <a target="_blank" href="https://github.com/ztrhgf/DevOps_Pipelines/blob/main/device-backup-pipeline.yml">device-backup-pipeline</a> pipeline 🙂</p>
<hr />
<h1 id="heading-why-automate-device-backups"><strong>Why Automate Device Backups?</strong></h1>
<ul>
<li><p><strong>Disaster Recovery:</strong> Quickly restore device secrets if Intune or Azure AD data is lost by accident or targeted attack.</p>
</li>
<li><p><strong>Audit &amp; Compliance:</strong> Maintain a historical record of device security data.</p>
</li>
<li><p><strong>Change Tracking:</strong> Easily see when device keys or credentials change.</p>
</li>
</ul>
<hr />
<h1 id="heading-pipeline-overview"><strong>Pipeline Overview</strong></h1>
<p>This pipeline runs every day at 5 am and performs the following steps:</p>
<ol>
<li><p><strong>Authenticates to Microsoft Graph using a secure service connection</strong> (Workload Federating identity)<strong>.</strong></p>
</li>
<li><p><strong>Exports device data</strong> (including BitLocker, LAPS, and FileVault keys) from Intune/Azure AD.</p>
</li>
<li><p><strong>Validates the backup</strong> to ensure all critical data is present.</p>
</li>
<li><p><strong>Exports each device’s data as a JSON file</strong> organized by OS and device serial number.</p>
</li>
<li><p><strong>Commits and tags the backup</strong> in your private Azure DevOps repository.</p>
</li>
</ol>
<hr />
<h1 id="heading-benefits"><strong>Benefits</strong></h1>
<ul>
<li><p><strong>Fully automated</strong>: No manual steps required.</p>
</li>
<li><p><strong>Version-controlled</strong>: Every backup is committed and tagged in Azure DevOps repository.</p>
</li>
<li><p><strong>Secure</strong>: Uses Azure DevOps service connections a.k.a. no stored secrets + supports AES encryption for the stored secrets</p>
</li>
<li><p><strong>Auditable</strong>: All device security data is available for review and recovery.</p>
</li>
<li><p><strong>Customizable</strong>: You choose what to back up</p>
</li>
</ul>
<hr />
<h1 id="heading-how-to-set-this-up">How to set this up</h1>
<p>The pipeline <code>device-backup-pipeline.yml</code> itself is stored in my <a target="_blank" href="https://github.com/ztrhgf/DevOps_Pipelines/blob/main/device-backup-pipeline.yml">GitHub</a>.</p>
<p>In general, you need to:</p>
<ul>
<li><p>Create a private Azure DevOps repository</p>
</li>
<li><p>Set up a new pipeline (based on <code>device-backup-pipeline.yml</code> content)</p>
</li>
<li><p>Give the pipeline account <strong>Contribute</strong> permission so that it can push changes to the repository</p>
</li>
<li><p>Create a <a target="_blank" href="https://doitpshway.com/how-to-easily-backup-your-azure-environment-using-entraexporter-and-azure-devops-pipeline#heading-workload-federating-identity">Workload Federating identity</a> (WIF) so that the running pipeline has access to Intune and Azure via the Graph Api</p>
</li>
</ul>
<p>If you want to encrypt the stored secrets:</p>
<ul>
<li><p>Create a <strong>strong password</strong> and save it to <strong>Azure KeyVault</strong></p>
</li>
<li><p>Grant the WIF identity the <strong>Key Vault Secrets User</strong> role over the created secret</p>
</li>
</ul>
<p>I will not go into detail about how to create an Azure DevOps repository, create the pipeline, or grant permissions to it. It’s all <a target="_blank" href="https://doitpshway.com/how-to-easily-backup-your-azure-environment-using-entraexporter-and-azure-devops-pipeline#heading-azure-devops-repository">described in one of my previous posts</a> already.</p>
<p>The only two things that are specific to this pipeline are:</p>
<ul>
<li><p>Graph Api permissions</p>
</li>
<li><p>Pipeline variables that need to be set to fit your environment</p>
</li>
</ul>
<h3 id="heading-what-graph-api-permissions-need-to-be-granted-to-the-pipeline-service-connection-principal">What Graph Api permissions need to be granted to the pipeline service connection principal:</h3>
<ul>
<li><p><code>DeviceManagementManagedDevices.Read.All</code></p>
</li>
<li><p><code>BitlockerKey.Read.All</code></p>
</li>
<li><p><code>DeviceLocalCredential.Read.All</code></p>
</li>
<li><p><code>DeviceManagementConfiguration.Read.All</code></p>
</li>
<li><p><code>DeviceManagementManagedDevices.PrivilegedOperations.All</code></p>
</li>
<li><p><code>User.ReadBasic.All</code></p>
</li>
<li><p><code>Device.Read.All</code></p>
</li>
</ul>
<h3 id="heading-pipeline-variables">Pipeline variables</h3>
<p>Before you can run the pipeline, you have to set the variables section! Each variable is commented, so it should be quite straightforward.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753960621812/e2adf275-d060-499e-a238-13c723814a2d.png" alt class="image--center mx-auto" /></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">I highly recommend that you <a target="_self" href="https://doitpshway.com/how-to-easily-backup-your-azure-environment-using-entraexporter-and-azure-devops-pipeline#heading-send-a-notification-in-case-the-pipeline-fails">set up email notifications</a> in case the pipeline exits unexpectedly. So you know your backup isn’t working correctly! Moreover, the function fails on purpose when there are no devices found, nor BitLocker keys, nor LAPS nor FileVault keys, because it probably means there is something wrong going.</div>
</div>

<hr />
<h1 id="heading-security-concerns">Security concerns</h1>
<p>This repository will contain <strong>highly sensitive data</strong>, so you <strong>must be very cautious</strong> about who has access, where the data is stored, and other security considerations.</p>
<p>There are some general tips</p>
<ul>
<li><p>Use the <strong>built-in encryption</strong> feature</p>
<ul>
<li>AES encryption will protect the secrets, just make sure only the right persons have access to the KeyVault encryption key!</li>
</ul>
</li>
<li><p>Make sure the <strong>repository is set to private</strong> (not public)</p>
</li>
<li><p>Make sure <strong>only relevant people have access to the repository</strong></p>
</li>
<li><p>Use a <a target="_blank" href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/windows-agent?view=azure-devops&amp;tabs=IP-V4">self-hosted</a> agent (on a Tier-0 server) to run the pipeline</p>
</li>
</ul>
<hr />
<h1 id="heading-what-the-backup-structure-looks-like">What the backup structure looks like</h1>
<p>The backup structure looks like this 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754039678900/ead5f923-80d6-449b-a7b9-6c0b3c856235.png" alt class="image--center mx-auto" /></p>
<p>So you have device data exported as JSON files, separated into two folders <code>MacOS</code> and <code>Windows</code> based on the device operating system. The device <strong>serial number is considered a unique identifier</strong>; hence, it is used as the JSON file name.</p>
<hr />
<h1 id="heading-faq">FAQ</h1>
<ul>
<li><p><strong>How to decrypt a secret?</strong></p>
<ul>
<li><p>Just use the following PowerShell code</p>
<ul>
<li><pre><code class="lang-powershell">      <span class="hljs-variable">$encryptionKey</span> = <span class="hljs-built_in">Get-AzKeyVaultSecret</span> <span class="hljs-literal">-VaultName</span> <span class="hljs-string">"&lt;someKeyVaultName&gt;"</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"&lt;someSecretName&gt;"</span>
      <span class="hljs-variable">$encryptionKeyValue</span> = <span class="hljs-variable">$encryptionKey</span>.SecretValue | <span class="hljs-built_in">ConvertFrom-SecureString</span> <span class="hljs-literal">-AsPlainText</span>
      <span class="hljs-variable">$decryptedText</span> = <span class="hljs-built_in">ConvertFrom-EncryptedString</span> <span class="hljs-literal">-EncryptedText</span> <span class="hljs-string">"&lt;encryptedSecret&gt;"</span> <span class="hljs-literal">-Key</span> <span class="hljs-variable">$encryptionKeyValue</span>
</code></pre>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>What happens when the device gets deleted from the Azure/Intune</strong></p>
<ul>
<li>Device backup a.k.a. <code>&lt;deviceSerial&gt;.json</code> file will stay intact in your backup</li>
</ul>
</li>
<li><p><strong>What happens when the device gets reinstalled?</strong></p>
<ul>
<li>The device backup (JSON file) will be updated with the new information</li>
</ul>
</li>
<li><p><strong>What if I need to see what the LAPS password was set to a week ago (I restored the device from the backup, or for any other reason)</strong></p>
<ul>
<li><p>Use the <code>History</code> tab in the DevOps portal to show the previous device backup versions</p>
<p>  <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753691827159/1eb133c9-f305-45e7-b4e7-fe2c367e6d88.png" alt class="image--center mx-auto" /></p>
</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-conclusion"><strong>Conclusion</strong></h1>
<p>With this Azure DevOps pipeline, you can rest easy knowing your Intune device security data is safely backed up, versioned, and ready for disaster recovery or audit. Adapt the scripts to your environment, and you’ll have a powerful, hands-off backup solution for your device secrets.</p>
]]></content:encoded></item><item><title><![CDATA[How to use Microsoft Graph Api Batching to speed up your scripts]]></title><description><![CDATA[Graph Api batching is a great way to improve the performance of your Graph API-related scripts dramatically.
It enables parallel execution of up to 20 Graph API calls, which is fantastic, but there is one tiny little problem. You have to write your o...]]></description><link>https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts</link><guid isPermaLink="true">https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts</guid><category><![CDATA[Powershell]]></category><category><![CDATA[Graph]]></category><category><![CDATA[APIs]]></category><category><![CDATA[Batch Processing]]></category><category><![CDATA[performance]]></category><category><![CDATA[Azure]]></category><category><![CDATA[intune]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Sun, 15 Jun 2025 17:07:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750006949708/61f5003e-72a9-416e-ba01-bb0667890efc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Graph Api batching is a great way to <strong>improve the performance</strong> of your Graph API-related scripts dramatically.</p>
<p>It enables <strong>parallel execution of up to 20 Graph API calls,</strong> which is fantastic, but there is one tiny little problem. You have to write your own logic for managing pagination, throttling, server-side errors recovery, and more.</p>
<p>Well, you don’t have to anymore, because of my new functions <code>New-GraphBatchRequest</code>, <code>Invoke-GraphBatchRequest</code> hosted in the PowerShell module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a> 👍.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There is a similar <a target="_self" href="https://doitpshway.com/supercharge-your-azure-api-calls-master-azure-resource-manager-batching-with-powershell"><strong>batching feature for the Azure Resource Graph Api</strong></a> <a target="_self" href="https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts">😎</a></div>
</div>

<hr />
<h2 id="heading-performance-boost-exampleshttpsdoitpshwaycomhow-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts"><a target="_blank" href="https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts">Performance boost examples</a></h2>
<p><a target="_blank" href="https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts">In the imag</a>e below, you can see the huge performance boost provided by using batching. <strong>From 50 seconds, I was able to pull all my Intune policies in just 11 seconds.</strong> And the difference can be even bigger in larger environments 😎</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1753269656357/21d35d27-2295-44db-b588-c4c83b229d4c.png" alt class="image--center mx-auto" /></p>
<p>There is a table of some of my other functions that were rewritten to use batching, along with the performance gain I achieved.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Command</strong></td><td><strong>Speed without batching</strong></td><td><strong>Speed with batching</strong></td><td><strong>Source</strong></td></tr>
</thead>
<tbody>
<tr>
<td>single api call (using Invoke-MgGraphRequest)</td><td><mark>0,11 s</mark></td><td>0,13 s</td><td></td></tr>
<tr>
<td>Get-IntuneDeviceHardware</td><td>79 s</td><td><mark>15 s</mark></td><td><a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a></td></tr>
<tr>
<td>Get-IntunePolicy</td><td>50 s</td><td><mark>11 s</mark></td><td><a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a></td></tr>
<tr>
<td>Get-IntuneDiscoveredApp</td><td>145 s</td><td><mark>29 s</mark></td><td><a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a></td></tr>
<tr>
<td>Get-PIMGroup</td><td>88 s</td><td><mark>23 s</mark></td><td><a target="_blank" href="https://www.powershellgallery.com/packages/AzurePIMStuff">AzurePIMStuff</a></td></tr>
<tr>
<td>device-backup-pipeline.yml</td><td>280 s</td><td><mark>65 s</mark></td><td><a target="_blank" href="https://doitpshway.com/exporting-bitlocker-laps-and-filevault-keys-from-intune-to-git-using-azure-devops-pipeline">https://doitpshway.com/exporting-bitlocker-laps-and-filevault-keys-from-intune-to-git-using-azure-devops-pipeline</a></td></tr>
</tbody>
</table>
</div><div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">FYI, when comparing <strong>batching </strong>versus PowerShell Core native <strong>parallel </strong>processing (<code>Foreach-Object -Parallel</code>), you get 42s (parallel) vs 29s (batching) in the function <code>Get-IntuneDiscoveredApp</code></div>
</div>

<hr />
<h1 id="heading-tldr">TL;DR</h1>
<ol>
<li><p>Install my PowerShell module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a></p>
<ol>
<li><pre><code class="lang-powershell">       <span class="hljs-built_in">Install-Module</span> MSGraphStuff
</code></pre>
</li>
</ol>
</li>
<li><p>Create a batch request using <code>New-GraphBatchRequest</code> function</p>
<ol>
<li><pre><code class="lang-powershell">        <span class="hljs-variable">$batchRequest</span> = <span class="hljs-selector-tag">@</span>(
           <span class="hljs-comment"># Azure app registrations</span>
           <span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/applications"</span> <span class="hljs-literal">-id</span> <span class="hljs-string">"Apps"</span>
           <span class="hljs-comment"># Azure enterprise applications</span>
           <span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/servicePrincipals"</span> <span class="hljs-literal">-id</span> <span class="hljs-string">"SPs"</span>
           <span class="hljs-comment"># Azure users</span>
           <span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/users"</span> <span class="hljs-literal">-id</span> <span class="hljs-string">"Users"</span>
           <span class="hljs-comment"># Azure groups</span>
           <span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/groups"</span> <span class="hljs-literal">-id</span> <span class="hljs-string">"Groups"</span>
           <span class="hljs-comment"># Intune devices</span>
           <span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/deviceManagement/managedDevices"</span> <span class="hljs-literal">-id</span> <span class="hljs-string">"IntuneDevices"</span>
           <span class="hljs-comment"># ... you can add as many Api urls as you wish</span>
       )
</code></pre>
</li>
</ol>
</li>
<li><p>Invoke the batch request using <code>Invoke-GraphBatchRequest</code> function to get the results</p>
<ol>
<li><pre><code class="lang-powershell">       <span class="hljs-variable">$batchResult</span> = <span class="hljs-built_in">Invoke-GraphBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$batchRequest</span> <span class="hljs-literal">-graphVersion</span> <span class="hljs-string">"beta"</span> <span class="hljs-literal">-Verbose</span>

       <span class="hljs-comment"># output all results at once</span>
       <span class="hljs-variable">$batchResult</span>

       <span class="hljs-comment"># split the results by the request id, so you can use them separately</span>
       <span class="hljs-variable">$apps</span> = <span class="hljs-variable">$batchResult</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"Apps"</span>
       <span class="hljs-variable">$sps</span> = <span class="hljs-variable">$batchResult</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"SPs"</span>
       <span class="hljs-variable">$users</span> = <span class="hljs-variable">$batchResult</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"Users"</span>
       <span class="hljs-variable">$groups</span> = <span class="hljs-variable">$batchResult</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"Groups"</span>
       <span class="hljs-variable">$intuneDevices</span> = <span class="hljs-variable">$batchResult</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"IntuneDevices"</span>
</code></pre>
</li>
</ol>
</li>
</ol>
<hr />
<h1 id="heading-graph-api-batching-introduction">Graph Api Batching introduction</h1>
<h2 id="heading-what-is-graph-api-batching">What is Graph Api batching?</h2>
<p>According to the <a target="_blank" href="https://learn.microsoft.com/en-us/graph/json-batching?tabs=http">official documentation</a>, Graph Api JSON batching allows clients to combine multiple requests into a single JSON object and a single HTTP call, reducing network roundtrips and improving efficiency. Microsoft Graph supports batching up to 20 requests into the JSON object.</p>
<p>To put it simply, <strong>batching allows you to process up to 20 Graph Api requests at the same time</strong> 😎</p>
<p>Most of the Microsoft portals use batching under the hood, btw.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There are other options for speeding up your code, like PowerShell Core <a target="_self" href="https://devblogs.microsoft.com/powershell/powershell-foreach-object-parallel-feature/">Foreach-Object -Parallel</a> feature. And it definitely has its place, but nothing is as fast as batching.</div>
</div>

<h2 id="heading-batching-advantages">Batching advantages</h2>
<ol>
<li><p><strong>Huge performance boost</strong></p>
<p> Parallel processing of up to 20 requests.</p>
</li>
<li><p><strong>Reduced Network Overhead</strong><br /> Instead of sending multiple HTTP requests, batching consolidates them into one, reducing the number of round-trips between client and server.</p>
</li>
<li><p><strong>Bypassing URL length limitations</strong></p>
<p> In cases where the filter clause is complex, the URL length might surpass limitations built into browsers or other HTTP clients. You can use JSON batching as a workaround for running these requests because the lengthy URL simply becomes part of the request payload.</p>
</li>
</ol>
<h2 id="heading-batching-drawbacks-and-how-i-solved-them">Batching drawbacks (and how I solved them)</h2>
<ol>
<li><p><strong>You need to know the requested Graph Api URL</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: You cannot use PowerShell commands like <code>Get-MgUser</code>, but the under-the-hood-used URL instead.</p>
</li>
<li><p><strong>SOLUTION</strong>: Check <a target="_blank" href="https://doitpshway.com/how-to-use-microsoft-graph-api-batching-to-speed-up-your-scripts#heading-how-to-find-out-the-graph-api-request-url">the Tips</a> to find out how to determine the correct API URL.</p>
</li>
</ol>
</li>
<li><p><strong>Complex error handling</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: Each sub-request in a batch can succeed or fail independently, requiring more sophisticated error-handling logic.</p>
</li>
<li><p><strong>SOLUTION</strong>: <code>Invoke-GraphBatchRequest</code> handles all server-side errors by retrying the request.</p>
</li>
</ol>
</li>
<li><p><strong>Rate limiting still applies</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: Batching doesn’t bypass Microsoft Graph’s throttling policies. If you exceed limits, your batch requests can still <a target="_blank" href="https://learn.microsoft.com/en-us/graph/throttling#throttling-and-batching">be throttled</a>.</p>
</li>
<li><p><strong>SOLUTION</strong>: <code>Invoke-GraphBatchRequest</code> retries the request(s) after the time specified in the server response.</p>
</li>
</ol>
</li>
<li><p><strong>Pagination still applies</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: Each sub-request in a batch can return only one page of the total number of results, requiring special handling logic.</p>
</li>
<li><p><strong>SOLUTION</strong>: <code>Invoke-GraphBatchRequest</code> handles pagination by creating another batch of paginated requests (URLs taken from <code>@odata.nextLink</code> property).</p>
</li>
</ol>
</li>
<li><p><strong>Separate API versions</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: You can’t combine requests against <em>beta</em> and <em>v1.0</em> api endpoints in the same batch.</p>
</li>
<li><p><strong>SOLUTION</strong>: Currently, none. But it’s in my to-do to allow requesting both api versions in the <code>Invoke-GraphBatchRequest</code> (by separating the batches by inner logic).</p>
</li>
</ol>
</li>
<li><p><strong>20 requests per batch limitation</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: When sending a batch request to <code>https://graph.microsoft.com/&lt;apiVersion&gt;/$batch</code> you cannot send more than 20 requests per batch.</p>
</li>
<li><p><strong>SOLUTION</strong>: <code>Invoke-GraphBatchRequest</code> handles batch-requests-limit by automatically splitting batch requests into chunks of 20.</p>
</li>
</ol>
</li>
<li><p><strong>Payload size limits</strong></p>
<ol>
<li><p><strong>PROBLEM</strong>: The total size of a batch request is limited (typically 4 MB), which can be restrictive for large data operations.</p>
</li>
<li><p><strong>SOLUTION</strong>: None. In the size of my company, I haven’t encountered this limitation.</p>
</li>
</ol>
</li>
</ol>
<p>Frankly, those drawbacks kept me away from using batching for quite a long time.</p>
<p>One of the things that finally made me adopt the batching and solve the mentioned issues was working on my <a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff/1.6.4">Get-IntuneDeviceHardware</a> function. A Graph Api URL that needs to be used to get hardware information requires querying devices one by one, which can be super slow!</p>
<hr />
<h1 id="heading-use-cases">Use cases</h1>
<h2 id="heading-information-that-can-be-gathered-only-one-at-a-time">Information that can be gathered only <strong>one-at-a-time</strong></h2>
<p>As mentioned, Intune device <strong>hardware inventory</strong> data must be requested device by device (<a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff/1.6.4">Get-IntuneDeviceHardware</a>). The same applies to <strong>discovered apps</strong> (<a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff/1.6.4">Get-IntuneDiscoveredApp</a>) or getting Bitlocker keys, FileVault keys, or a lot of PIM-related stuff.</p>
<h2 id="heading-you-are-making-more-than-one-graph-api-request-in-your-code">You are making more than one Graph Api request in your code</h2>
<p>It doesn’t make sense to use batching for one request. But more than one? Worth it!</p>
<h2 id="heading-automatic-api-throttling-on-specific-uris-when-expand-is-used">Automatic Api throttling on specific URIs when ‘expand’ is used</h2>
<p>For example, when working with <code>/deviceManagement/configurationPolicies?$expand=settings,assignments</code> you will encounter automatic throttling on the server side. To overcome this problem, you have to split the calls: <code>/deviceManagement/configurationPolicies/&lt;policyId&gt;/settings</code>, <code>/deviceManagement/configurationPolicies/&lt;policyId&gt;/assignments</code>. Btw, this is what I am doing in my improved <a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff/1.7.0">Get-IntunePolicy</a> function, where thanks to batching, I was able to <strong>reduce the run time from 60 seconds to just 8</strong> (in my environment).</p>
<hr />
<h1 id="heading-tips">Tips</h1>
<h2 id="heading-how-to-create-a-batch-request-for-the-invoke-graphbatchrequest-function">How to create a batch request for the Invoke-GraphBatchRequest function</h2>
<p><code>Invoke-GraphBatchRequest</code> function accepts an array of requests PSObjects (via <code>batchRequest</code> parameter) where at minimum, you have to specify the following properties:</p>
<ul>
<li><p>Request <code>id</code></p>
<ul>
<li>can be later used to separate the results</li>
</ul>
</li>
<li><p>HTTP <code>method</code></p>
<ul>
<li><code>GET</code> in most cases</li>
</ul>
</li>
<li><p>Request <code>url</code></p>
<ul>
<li>in relative form (without the 'https://graph.microsoft.com/&lt;apiversion&gt;' prefix)</li>
</ul>
</li>
</ul>
<p>You can create it manually like below</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># create batch request</span>
<span class="hljs-variable">$batchRequest</span> = <span class="hljs-selector-tag">@</span>(
        [<span class="hljs-type">PSCustomObject</span>]<span class="hljs-selector-tag">@</span>{
            id     = <span class="hljs-string">"app"</span>
            method = <span class="hljs-string">"GET"</span>
            URL    = <span class="hljs-string">"applications"</span> <span class="hljs-comment"># stands for https://graph.microsoft.com/&lt;apiversion&gt;/applications</span>
        },
        [<span class="hljs-type">PSCustomObject</span>]<span class="hljs-selector-tag">@</span>{
            id     = <span class="hljs-string">"sp"</span>
            method = <span class="hljs-string">"GET"</span>
            URL    = <span class="hljs-string">"servicePrincipals"</span> <span class="hljs-comment"># stands for https://graph.microsoft.com/&lt;apiversion&gt;/servicePrincipals</span>
        }
)
</code></pre>
<p>Or via my function <code>New-GraphBatchRequest</code> like this</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$batchRequest</span> = <span class="hljs-selector-tag">@</span>((<span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-Url</span> <span class="hljs-string">"applications"</span> <span class="hljs-literal">-Id</span> <span class="hljs-string">"app"</span>), (<span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-Url</span> <span class="hljs-string">"servicePrincipals"</span> <span class="hljs-literal">-Id</span> <span class="hljs-string">"sp"</span>))
</code></pre>
<p>And then use it like</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># run batch request</span>
<span class="hljs-variable">$allResults</span> = <span class="hljs-built_in">Invoke-GraphBatchRequest</span> <span class="hljs-literal">-batchRequest</span> <span class="hljs-variable">$batchRequest</span>

<span class="hljs-comment"># separate the results by request id</span>
<span class="hljs-variable">$applicationList</span> = <span class="hljs-variable">$allResults</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"app"</span>
<span class="hljs-variable">$servicePrincipalList</span> = <span class="hljs-variable">$allResults</span> | ? RequestId <span class="hljs-operator">-eq</span> <span class="hljs-string">"sp"</span>
</code></pre>
<p>See the <a target="_blank" href="https://learn.microsoft.com/en-us/graph/json-batching?tabs=http#creating-a-batch-request">documentation</a> and <a target="_blank" href="https://learn.microsoft.com/en-us/graph/json-batching?tabs=http#example-json-batch-request">examples</a> for more details.</p>
<h2 id="heading-how-to-create-dozens-of-requests-where-only-the-id-part-of-the-url-is-changing">How to create dozens of requests where only the ID part of the URL is changing</h2>
<p>This is exactly why I’ve created <code>New-GraphBatchRequest</code> function originally, because you can give it a URL with <code>&lt;placeholder&gt;</code> string inside (<code>url</code> parameter), plus an array of strings (<code>placeholder</code> parameter) to generate a customized URL request for each of them.</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$deviceId</span> = (<span class="hljs-built_in">Get-MgBetaDeviceManagementManagedDevice</span> <span class="hljs-literal">-Property</span> id <span class="hljs-literal">-All</span>).Id

<span class="hljs-built_in">New-GraphBatchRequest</span> <span class="hljs-literal">-url</span> <span class="hljs-string">"/deviceManagement/managedDevices/&lt;placeholder&gt;?`$select=id,devicename&amp;`$expand=DetectedApps"</span> <span class="hljs-literal">-placeholder</span> <span class="hljs-variable">$deviceId</span>
</code></pre>
<h2 id="heading-how-to-find-out-the-graph-api-request-url">How to find out the Graph Api request URL</h2>
<p>OK, so you have some function or script that uses official Graph Api SDK cmdlets a.k.a. <code>Get-MgUser</code>, <code>Get-MgDevice</code>, … and you want to know what URLs are used under the hood?</p>
<p>You have the following options:</p>
<ul>
<li><p>You can add <code>-Debug</code> switch to any <code>-Mg*</code> cmdlet and it will return the called URL (including the used <code>filter</code> and <code>property</code> parameters)</p>
</li>
<li><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750005082967/3418540b-0f2e-44ed-bd3a-77bbff793fd9.png" alt class="image--center mx-auto" /></p>
<p>  You can find any <code>-Mg*</code> cmdlet using <code>Find-MgGraphCommand</code> to get the called (relative) base URL</p>
</li>
<li><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1749236399588/d18b9d01-3eef-4387-9520-be4c369add66.png" alt class="image--center mx-auto" /></p>
</li>
</ul>
<p>Or if you like, you can search the <a target="_blank" href="https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0">official documentation</a> :)</p>
<p>Or maybe you want to know how the data that you can see on some Intune/Azure/… portal is gathered? In such case, use the developer tools feature (F12) in your browser and on the tab <code>Network</code> search for <code>graph</code> string.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1749237140193/75ebd2a9-dc49-46b0-995c-8d788a651d3e.png" alt class="image--center mx-auto" /></p>
<p>As you can see Azure portal uses batching for Groups retrieval :)</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Beware that in case of batch requests, you have to check <code>Payload</code> sub-tab to get the actual request URL.</div>
</div>

<p>When batching is NOT used, you can get the requested URL in the <code>Headers</code> sub-tab</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1749237748075/0c2caaf4-fbf7-48a7-b42e-b7b9f74e3fa2.png" alt class="image--center mx-auto" /></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There is also <a target="_self" href="https://chromewebstore.google.com/detail/gdhbldfajbedclijgcmmmobdbnjhnpdh?utm_source=item-share-cb">Chrome extension X-Ray</a> that filters those Graph Api urls for you, but I am personally a little bit afraid of using any extension under my administrator account :)</div>
</div>

<hr />
<h1 id="heading-summary">Summary</h1>
<p>Graph Api Batching is a great way to improve the performance of your scripts, but you have to use functions like mine <code>Invoke-GraphBatchRequest</code> (<a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a> module) to overcome the lack of built-in support for pagination, throttling, and server-side errors handling.</p>
<p>Happy coding 😎</p>
]]></content:encoded></item><item><title><![CDATA[Comparing Intune Security Baseline settings]]></title><description><![CDATA[I've released a new PowerShell function called Compare-IntuneSecurityBaseline in my IntuneStuff module.
This function allows you to easily identify the differences in settings between two Intune Security baselines. For instance, when Microsoft introd...]]></description><link>https://doitpshway.com/comparing-intune-security-baseline-settings</link><guid isPermaLink="true">https://doitpshway.com/comparing-intune-security-baseline-settings</guid><category><![CDATA[Powershell]]></category><category><![CDATA[intune]]></category><category><![CDATA[Security]]></category><category><![CDATA[Script]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Fri, 28 Mar 2025 07:09:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1743765830766/e927cbd5-5042-4733-a41f-bcef1e90d021.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've released a new PowerShell function called <code>Compare-IntuneSecurityBaseline</code> in my <a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a> module.</p>
<p>This function allows you to easily identify the differences in settings between two Intune Security baselines. For instance, when Microsoft introduces a new Security Baseline for Windows 10, you can quickly see how it varies from your currently deployed baseline.</p>
<hr />
<h1 id="heading-how-to-use">How to use</h1>
<pre><code class="lang-powershell"><span class="hljs-built_in">Install-Module</span> IntuneStuff

<span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-Scope</span> DeviceManagementConfiguration.Read.All

<span class="hljs-built_in">Compare-IntuneSecurityBaseline</span>
</code></pre>
<p>When you invoke <code>Compare-IntuneSecurityBaseline</code>, you will be interactively asked to select the baseline type.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743144647781/226ec6e5-7761-4de5-885b-2b8324a175a2.png" alt class="image--center mx-auto" /></p>
<p>And then select two baselines of such type to compare.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743144666891/91911416-106e-4238-9126-6795dfd84a9a.png" alt class="image--center mx-auto" /></p>
<p>Function exports both baselines as JSON objects and makes the comparison.</p>
<p>The result will be objects that look like this 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743144718816/c8babafa-9828-4646-856b-36e568800524.png" alt class="image--center mx-auto" /></p>
<p>What do the object columns contain</p>
<ul>
<li><p><strong>Result -</strong> type of change (whether the setting differs or is missing completely)</p>
</li>
<li><p><strong>Setting</strong> - name of the setting as is in the exported JSON file</p>
<ul>
<li>JSON name doesn’t match the setting names in the Intune GUI. Use just one of the keywords when searching the GUI (for example when searching for <code>device_vendor_msft_policy_config_defender_submitsamplesconsent</code> search the GUI for ‘samples’ or ‘consent’).</li>
</ul>
</li>
<li><p><strong>OldBslnValue</strong> - JSON value of the first baseline setting</p>
</li>
<li><p><strong>NewBslnValue</strong> - JSON value of the second baseline setting</p>
</li>
</ul>
<hr />
<h1 id="heading-summary">Summary</h1>
<p>With the function <code>Compare-IntuneSecurityBaseline</code> in place, we can now easily compare our current baselines with their newly released versions. Such information can help to decide which settings need to be modified to avoid breaking our environment etc 🙂</p>
]]></content:encoded></item><item><title><![CDATA[Identify employees using the free version of GitHub Copilot for Visual Studio Code (VSC) within your company environment]]></title><description><![CDATA[You should carefully consider whether you allow your employees to use free GitHub Copilot. This way they can potentially leak some sensitive company data and nobody wants that 🙂

Below is a short PowerShell script to identify the usage of the free G...]]></description><link>https://doitpshway.com/identify-employees-using-the-free-version-of-github-copilot-for-visual-studio-code-vsc-within-your-company-environment</link><guid isPermaLink="true">https://doitpshway.com/identify-employees-using-the-free-version-of-github-copilot-for-visual-studio-code-vsc-within-your-company-environment</guid><category><![CDATA[Security]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[vscode extensions]]></category><category><![CDATA[copilot]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 26 Feb 2025 10:51:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/MAYEkmn7G6E/upload/5613eb943eb4e74aae9a3485f5d9aaf5.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You should carefully consider whether you allow your employees to use <strong>free</strong> GitHub Copilot. This way they can potentially leak some sensitive company data and nobody wants that 🙂</p>
<hr />
<p>Below is a short PowerShell script to identify the usage of the free GitHub Copilot addon version in Visual Studio Code (VSC) IDE across your Windows clients.</p>
<p>You can run it via PowerShell remoting or like in my case via <a target="_blank" href="https://doitpshway.com/invoke-command-alternative-for-intune-managed-windows-devices">Intune on-demand remediation</a>.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Install-Module</span> IntuneStuff

<span class="hljs-comment"># code will check VSC log files for usage of free_limited_copilot copilot sku</span>
<span class="hljs-variable">$code</span> = { 
    <span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-literal">-Path</span> <span class="hljs-string">"C:\Users\*"</span> | ? Name <span class="hljs-operator">-NE</span> <span class="hljs-string">"Public"</span> | % {
        <span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-literal">-Path</span> (<span class="hljs-variable">$_</span>.FullName + <span class="hljs-string">"\AppData\Roaming\Code\logs\*"</span>) <span class="hljs-literal">-Recurse</span> <span class="hljs-literal">-Include</span> <span class="hljs-string">"GitHub Copilot Chat.log"</span> <span class="hljs-literal">-ErrorAction</span> SilentlyContinue | % {
            <span class="hljs-variable">$matchedContext</span> = <span class="hljs-built_in">Select-String</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$_</span>.FullName <span class="hljs-literal">-Pattern</span> <span class="hljs-string">"sku: free_limited_copilot"</span> <span class="hljs-literal">-Context</span> <span class="hljs-number">1</span>

            <span class="hljs-keyword">if</span> (<span class="hljs-variable">$matchedContext</span>) {
                <span class="hljs-variable">$userLine</span> = <span class="hljs-selector-tag">@</span>(<span class="hljs-variable">$matchedContext</span>)[<span class="hljs-number">0</span>] <span class="hljs-operator">-split</span> <span class="hljs-string">"`n"</span> | ? { <span class="hljs-variable">$_</span> <span class="hljs-operator">-match</span> <span class="hljs-string">"Got Copilot token for"</span> }
                <span class="hljs-variable">$user</span> = ([<span class="hljs-type">regex</span>]<span class="hljs-string">"Got Copilot token for (.+)<span class="hljs-variable">$</span>"</span>).Matches(<span class="hljs-variable">$userLine</span>).captures.groups[<span class="hljs-number">1</span>].value

                <span class="hljs-keyword">if</span> (<span class="hljs-variable">$user</span>) {
                    <span class="hljs-string">"On <span class="hljs-variable">$env:COMPUTERNAME</span> in '<span class="hljs-variable">$</span>(<span class="hljs-variable">$_</span>.FullName)' user '<span class="hljs-variable">$</span>(<span class="hljs-variable">$user</span>.trim())' is using free copilot"</span>
                } <span class="hljs-keyword">else</span> {
                    <span class="hljs-string">"On <span class="hljs-variable">$env:COMPUTERNAME</span> in '<span class="hljs-variable">$</span>(<span class="hljs-variable">$_</span>.FullName)' this info was found:`n<span class="hljs-variable">$matchedContext</span>"</span>
                }
            }
        }
    } 
}

<span class="hljs-comment"># invoke given code against all Windows Intune-managed devices</span>
<span class="hljs-built_in">Invoke-IntuneCommand</span> <span class="hljs-literal">-scriptBlock</span> <span class="hljs-variable">$code</span> <span class="hljs-literal">-waitTime</span> <span class="hljs-number">20</span>
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Fix for mismatch between Intune and Azure device compliance status]]></title><description><![CDATA[It happened to us several times that Azure showed company devices as non-compliant, but Intune showed them as compliant. And this wasn’t fixed over time.
This is quite a problem in case, you require device compliance in your Azure Conditional policie...]]></description><link>https://doitpshway.com/fix-for-mismatch-between-intune-and-azure-device-compliance-status</link><guid isPermaLink="true">https://doitpshway.com/fix-for-mismatch-between-intune-and-azure-device-compliance-status</guid><category><![CDATA[Azure]]></category><category><![CDATA[automation]]></category><category><![CDATA[Powershell]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Fri, 21 Feb 2025 13:37:13 GMT</pubDate><content:encoded><![CDATA[<p>It happened to us several times that <strong>Azure showed company devices as non-compliant, but Intune showed them as compliant</strong>. And this wasn’t fixed over time.</p>
<p>This is quite a problem in case, you require device compliance in your Azure Conditional policies a.k.a. users will be denied access to protected resources.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">You can check if you are affected using using PowerShell code mentioned in the <strong>Tips</strong> section</div>
</div>

<p>Internet says that device compliance status is synchronized to Azure only when a change occurs. So in case, something unexpected happens on the cloud side, it can happen that Azure doesn’t retrieve new compliance data.</p>
<p>In such case, you have two options:</p>
<ul>
<li><p>force another compliance status change on the device (by deploying a compliance policy that cannot be fulfilled and then removing such assignment to get a compliant device again)</p>
</li>
<li><p>use API to set device compliance state as required</p>
</li>
</ul>
<p>I will focus on the latter method because it doesn’t take hours to fix the issue, but seconds.</p>
<hr />
<h1 id="heading-solution">Solution</h1>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Before we start I want to say that compliance can be set via the <a target="_self" href="https://aadinternals.com/post/mdm/#marking-device-compliant-option-2-aad-graph-api">AADInternals module</a> too. But my solution supports FIDO auth and uses native commands so is more readable.</div>
</div>

<p>You can fix compliance mismatch issues on-demand, but I prefer an automated solution to make sure any future problems will be fixed automatically.</p>
<p>Therefore my solution uses</p>
<ul>
<li><p>Azure Automation to run the PowerShell code</p>
</li>
<li><p>Managed identity with appropriate Graph API permissions</p>
<ul>
<li><code>Device.ReadWrite.All</code>, <code>DeviceManagementManagedDevices.Read.All</code></li>
</ul>
</li>
</ul>
<p>Below is the PowerShell code that will create &amp; set Automation Account, but you can do all the stuff manually. It uses a combination of the official <a target="_blank" href="https://www.powershellgallery.com/packages/Az.Automation">Az.Automation</a> and my custom <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff">AzureResourceStuff</a> modules.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Install-Module</span> Az.Accounts, Az.Automation, AzureResourceStuff, AzureApplicationStuff

<span class="hljs-built_in">Connect-AzAccount</span>

<span class="hljs-variable">$automationAccountName</span> = <span class="hljs-string">"DeviceComplianceMismatchFixer"</span>
<span class="hljs-variable">$azureLocation</span> = <span class="hljs-string">"westeurope"</span>
<span class="hljs-variable">$automationResourceGroupName</span> = <span class="hljs-string">"&lt;chooseOne&gt;"</span>
<span class="hljs-variable">$customRuntimeName</span> = <span class="hljs-string">"PSH72"</span>
<span class="hljs-variable">$runbookName</span> = <span class="hljs-string">"fixer"</span>
<span class="hljs-variable">$scheduleName</span> = <span class="hljs-string">"every_hour"</span>

<span class="hljs-comment"># create Azure Automation Account</span>
<span class="hljs-string">"Creating Automation Account"</span>
<span class="hljs-variable">$automationAccount</span> = <span class="hljs-built_in">New-AzAutomationAccount</span> <span class="hljs-literal">-Name</span> <span class="hljs-variable">$automationAccountName</span> <span class="hljs-literal">-Location</span> <span class="hljs-variable">$azureLocation</span> <span class="hljs-literal">-ResourceGroupName</span> <span class="hljs-variable">$automationResourceGroupName</span> <span class="hljs-literal">-AssignSystemIdentity</span>


<span class="hljs-comment"># create Runtime Environment</span>
<span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
    runtimeName           = <span class="hljs-variable">$customRuntimeName</span>
    runtimeLanguage       = <span class="hljs-string">"PowerShell"</span>
    runtimeVersion        = <span class="hljs-string">"7.2"</span>
    resourceGroupName     = <span class="hljs-variable">$automationResourceGroupName</span>
    automationAccountName = <span class="hljs-variable">$automationAccountName</span>
}

<span class="hljs-string">"Creating custom environment runtime"</span>
<span class="hljs-variable">$customRuntime</span> = <span class="hljs-built_in">New-AzureAutomationRuntime</span> @<span class="hljs-keyword">param</span>


<span class="hljs-comment"># add required modules to the runtime</span>
<span class="hljs-string">'Microsoft.Graph.Authentication'</span>, <span class="hljs-string">'Microsoft.Graph.Identity.DirectoryManagement'</span>, <span class="hljs-string">'Microsoft.Graph.DeviceManagement'</span> | % {   
    <span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
        resourceGroupName     = <span class="hljs-variable">$automationResourceGroupName</span>
        automationAccountName = <span class="hljs-variable">$automationAccountName</span>
        runtimeName           = <span class="hljs-variable">$customRuntimeName</span>
        moduleName            = <span class="hljs-variable">$_</span>
        dontWait              = <span class="hljs-variable">$true</span>
    }
    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$moduleVersion</span>) {
        <span class="hljs-variable">$param</span>.moduleVersion = <span class="hljs-variable">$moduleVersion</span>
    }

    <span class="hljs-string">"Adding PSGallery module '<span class="hljs-variable">$_</span>'"</span>
    <span class="hljs-built_in">New-AzureAutomationRuntimeModule</span> @<span class="hljs-keyword">param</span>
}

<span class="hljs-comment"># create runbook</span>
<span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
    Name                  = <span class="hljs-variable">$runbookName</span>
    <span class="hljs-built_in">Type</span>                  = <span class="hljs-string">"PowerShell"</span>
    ResourceGroupName     = <span class="hljs-variable">$automationResourceGroupName</span>
    AutomationAccountName = <span class="hljs-variable">$automationAccountName</span>
}

<span class="hljs-string">"Creating runbook"</span>
<span class="hljs-variable">$runbook</span> = <span class="hljs-built_in">New-AzAutomationRunbook</span> @<span class="hljs-keyword">param</span>


<span class="hljs-comment"># set runbook runtime</span>
<span class="hljs-string">"Setting runbook runtime to '<span class="hljs-variable">$customRuntimeName</span>'"</span>
<span class="hljs-variable">$null</span> = <span class="hljs-built_in">Set-AzureAutomationRunbookRuntime</span> <span class="hljs-literal">-resourceGroupName</span> <span class="hljs-variable">$automationResourceGroupName</span> <span class="hljs-literal">-automationAccountName</span> <span class="hljs-variable">$automationAccountName</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-variable">$customRuntimeName</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-variable">$runbookName</span>


<span class="hljs-comment"># set runbook code</span>
<span class="hljs-string">"Setting runbook content"</span>
<span class="hljs-variable">$runbookContent</span> = <span class="hljs-string">@'
# fix mismatch in compliance status between Intune and Azure (Intune wins btw)
# requires scope: Device.ReadWrite.All, DeviceManagementManagedDevices.Read.All

$WHATIF = $false # set to $true if you don;t want to do any changes to your environment!

Connect-MgGraph -Identity

$azureDeviceList = Get-MgDevice -All -Property DeviceId, Id, IsCompliant

foreach ($device in (Get-MgDeviceManagementManagedDevice -All -Property DeviceName, AzureAdDeviceId, ComplianceState -Filter "ManagedDeviceOwnerType eq 'company'" | sort DeviceName)) {
    $deviceName = $device.DeviceName
    $intuneCompliance = $device.ComplianceState
    $id = $device.AzureAdDeviceId

    "Processing $deviceName ($id)"

    if ($intuneCompliance -in "compliant", "inGracePeriod") {
        $intuneIsCompliant = $true
    } else {
        $intuneIsCompliant = $false
    }

    $aadDevice = $azureDeviceList | ? DeviceId -EQ $id

    if (!$aadDevice) {
        " - Corresponding Azure record is missing!"
        continue
    }

    $aadDeviceId = $aadDevice.Id
    $azureIsCompliant = $aadDevice.IsCompliant

    if ($intuneIsCompliant -ne $azureIsCompliant) {
        # convert boolean to match intune status message
        switch ($azureIsCompliant) {
            $true { $azureIsCompliant = "compliant" }
            $false { $azureIsCompliant = "noncompliant" }
        }

        " - $deviceName has mismatch in compliance status! Intune: $intuneCompliance, Azure: $azureIsCompliant. Fixing"

        if ($azureIsCompliant -eq $null) {
            " - Unable to fix. Azure object doesn't have any compliance value a.k.a. is invalid."
            continue
        }

        if (!$WHATIF) {
            $body = @{
                isCompliant = $intuneIsCompliant
            }
            $url = "https://graph.microsoft.com/v1.0/devices/$aadDeviceId"

            # set compliance status
            Invoke-MgGraphRequest -Method PATCH -Uri $url -Body ($body | ConvertTo-Json)

            # check compliance status
            $device = Invoke-MgGraphRequest -Method GET -Uri $url
            if ($device.isCompliant -ne $intuneIsCompliant) {
                throw "Compliance wasn't changed!"
            }
        }
    }
}
'@</span>

<span class="hljs-variable">$null</span> = <span class="hljs-built_in">Set-AzureAutomationRunbookContent</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-variable">$runbookName</span> <span class="hljs-literal">-resourceGroupName</span> <span class="hljs-variable">$automationResourceGroupName</span> <span class="hljs-literal">-automationAccountName</span> <span class="hljs-variable">$automationAccountName</span> <span class="hljs-literal">-content</span> <span class="hljs-variable">$runbookContent</span> <span class="hljs-literal">-publish</span>


<span class="hljs-comment"># set runbook schedule</span>
<span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
    Name                  = <span class="hljs-variable">$scheduleName</span>
    StartTime             = (<span class="hljs-built_in">Get-Date</span>).AddMinutes(<span class="hljs-number">10</span>)
    ResourceGroupName     = <span class="hljs-variable">$automationResourceGroupName</span>
    AutomationAccountName = <span class="hljs-variable">$automationAccountName</span>
    HourInterval          = <span class="hljs-number">1</span>
}

<span class="hljs-string">"Creating schedule '<span class="hljs-variable">$scheduleName</span>'"</span>
<span class="hljs-variable">$schedule</span> = <span class="hljs-built_in">New-AzAutomationSchedule</span> @<span class="hljs-keyword">param</span>
<span class="hljs-string">"Linking schedule '<span class="hljs-variable">$scheduleName</span>' to the runbook"</span>
<span class="hljs-variable">$scheduleLink</span> = <span class="hljs-built_in">Register-AzAutomationScheduledRunbook</span> <span class="hljs-literal">-ScheduleName</span> <span class="hljs-variable">$scheduleName</span> <span class="hljs-literal">-RunbookName</span> <span class="hljs-variable">$runbookName</span> <span class="hljs-literal">-ResourceGroupName</span> <span class="hljs-variable">$automationResourceGroupName</span> <span class="hljs-literal">-AutomationAccountName</span> <span class="hljs-variable">$automationAccountName</span>


<span class="hljs-comment"># grant Automation Account System Managed Identity required permissions</span>
<span class="hljs-built_in">Grant-AzureServicePrincipalPermission</span> <span class="hljs-literal">-servicePrincipalId</span> <span class="hljs-string">'&lt;idOfTheManagedIdentity&gt;'</span> <span class="hljs-literal">-permissionType</span> application <span class="hljs-literal">-permissionList</span> Device.ReadWrite.All, DeviceManagementManagedDevices.Read.All
</code></pre>
<p>And there you have it! Your automation is now set up to address any future compliance mismatches 😎</p>
<p>You can check the runbook job output in the Azure portal to see what issues were fixed etc.</p>
<hr />
<h1 id="heading-tips">Tips</h1>
<h2 id="heading-how-to-find-devices-with-compliance-status-mismatch">How to find devices with compliance status mismatch</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># Find devices with compliance status mismatch</span>

<span class="hljs-string">'Microsoft.Graph.Authentication'</span>, <span class="hljs-string">'Microsoft.Graph.Identity.DirectoryManagement'</span>, <span class="hljs-string">'Microsoft.Graph.DeviceManagement'</span> | % {
    <span class="hljs-built_in">Import-Module</span> <span class="hljs-variable">$_</span>
}

<span class="hljs-built_in">Connect-MgGraph</span>

<span class="hljs-variable">$azureDeviceList</span> = <span class="hljs-built_in">Get-MgDevice</span> <span class="hljs-literal">-All</span> <span class="hljs-literal">-Property</span> DeviceId, Id, IsCompliant

<span class="hljs-keyword">foreach</span> (<span class="hljs-variable">$device</span> <span class="hljs-keyword">in</span> (<span class="hljs-built_in">Get-MgDeviceManagementManagedDevice</span> <span class="hljs-literal">-All</span> <span class="hljs-literal">-Property</span> DeviceName, AzureAdDeviceId, ComplianceState <span class="hljs-literal">-Filter</span> <span class="hljs-string">"ManagedDeviceOwnerType eq 'company'"</span> | <span class="hljs-built_in">sort</span> DeviceName)) {
    <span class="hljs-variable">$deviceName</span> = <span class="hljs-variable">$device</span>.DeviceName
    <span class="hljs-variable">$intuneCompliance</span> = <span class="hljs-variable">$device</span>.ComplianceState
    <span class="hljs-variable">$id</span> = <span class="hljs-variable">$device</span>.AzureAdDeviceId

    <span class="hljs-string">"Processing <span class="hljs-variable">$deviceName</span> (<span class="hljs-variable">$id</span>)"</span>

    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$intuneCompliance</span> <span class="hljs-operator">-in</span> <span class="hljs-string">"compliant"</span>, <span class="hljs-string">"inGracePeriod"</span>) {
        <span class="hljs-variable">$intuneIsCompliant</span> = <span class="hljs-variable">$true</span>
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-variable">$intuneIsCompliant</span> = <span class="hljs-variable">$false</span>
    }

    <span class="hljs-variable">$aadDevice</span> = <span class="hljs-variable">$azureDeviceList</span> | ? DeviceId <span class="hljs-operator">-EQ</span> <span class="hljs-variable">$id</span>

    <span class="hljs-keyword">if</span> (!<span class="hljs-variable">$aadDevice</span>) {
        <span class="hljs-string">" - Corresponding Azure record is missing!"</span>
        <span class="hljs-keyword">continue</span>
    }

    <span class="hljs-variable">$aadDeviceId</span> = <span class="hljs-variable">$aadDevice</span>.Id
    <span class="hljs-variable">$azureIsCompliant</span> = <span class="hljs-variable">$aadDevice</span>.IsCompliant

    <span class="hljs-keyword">if</span> (<span class="hljs-variable">$intuneIsCompliant</span> <span class="hljs-operator">-ne</span> <span class="hljs-variable">$azureIsCompliant</span>) {
        <span class="hljs-comment"># convert boolean to match intune status message</span>
        <span class="hljs-keyword">switch</span> (<span class="hljs-variable">$azureIsCompliant</span>) {
            <span class="hljs-variable">$true</span> { <span class="hljs-variable">$azureIsCompliant</span> = <span class="hljs-string">"compliant"</span> }
            <span class="hljs-variable">$false</span> { <span class="hljs-variable">$azureIsCompliant</span> = <span class="hljs-string">"noncompliant"</span> }
        }

        <span class="hljs-string">" - <span class="hljs-variable">$deviceName</span> has mismatch in compliance status! Intune: <span class="hljs-variable">$intuneCompliance</span>, Azure: <span class="hljs-variable">$azureIsCompliant</span>. Fixing"</span>
    }
}
</code></pre>
<h2 id="heading-how-to-set-device-compliance-status">How to set device compliance status</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># Set device compliance status in Azure</span>

<span class="hljs-comment"># objectId of the Azure device</span>
<span class="hljs-variable">$aadDeviceId</span> = <span class="hljs-string">'&lt;deviceObjectId&gt;'</span>
<span class="hljs-comment"># what should be the compliance status</span>
<span class="hljs-variable">$isCompliant</span> = <span class="hljs-variable">$true</span> <span class="hljs-comment"># $false</span>

<span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-Scope</span> <span class="hljs-string">'Device.ReadWrite.All'</span>, <span class="hljs-string">'DeviceManagementManagedDevices.Read.All'</span>

<span class="hljs-variable">$body</span> = <span class="hljs-selector-tag">@</span>{
    isCompliant = <span class="hljs-variable">$isCompliant</span>
}
<span class="hljs-variable">$url</span> = <span class="hljs-string">"https://graph.microsoft.com/v1.0/devices/<span class="hljs-variable">$aadDeviceId</span>"</span>

<span class="hljs-comment"># set compliance status</span>
<span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Method</span> PATCH <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$url</span> <span class="hljs-literal">-Body</span> (<span class="hljs-variable">$body</span> | <span class="hljs-built_in">ConvertTo-Json</span>)

<span class="hljs-comment"># check compliance status</span>
<span class="hljs-variable">$device</span> = <span class="hljs-built_in">Invoke-MgGraphRequest</span> <span class="hljs-literal">-Method</span> GET <span class="hljs-literal">-Uri</span> <span class="hljs-variable">$url</span>
<span class="hljs-keyword">if</span> (<span class="hljs-variable">$device</span>.isCompliant <span class="hljs-operator">-ne</span> <span class="hljs-variable">$isCompliant</span>) {
    <span class="hljs-keyword">throw</span> <span class="hljs-string">"Compliance wasn't changed!"</span>
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Convert MS security baselines to Azure ARC Guest Configuration packages]]></title><description><![CDATA[In this post, I will show you how to easily convert official Microsoft Windows Security Baselines into Azure ARC Guest Configuration (DSC) packages. So you can easily deploy them to your ARC-managed clients or any DSC-managed client.
In general, you ...]]></description><link>https://doitpshway.com/convert-ms-security-baselines-to-azure-arc-guest-configuration-packages</link><guid isPermaLink="true">https://doitpshway.com/convert-ms-security-baselines-to-azure-arc-guest-configuration-packages</guid><category><![CDATA[gpo]]></category><category><![CDATA[Azure]]></category><category><![CDATA[arc]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[dsc]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Fri, 21 Feb 2025 10:58:34 GMT</pubDate><content:encoded><![CDATA[<p>In this post, I will show you how to easily convert official <a target="_blank" href="https://techcommunity.microsoft.com/blog/microsoft-security-baselines/windows-server-2025-security-baseline/4358733">Microsoft Windows Security Baselines</a> into Azure ARC Guest Configuration (DSC) packages. So you can easily deploy them to your ARC-managed clients or any DSC-managed client.</p>
<p>In general, you can use this guideline to convert any GPO to DSC!</p>
<hr />
<ol>
<li><p>Start with downloading <a target="_blank" href="https://www.microsoft.com/en-us/download/details.aspx?id=55319">Microsoft Security Compliance Toolkit 1.0</a> ZIP file which contains the mentioned baseline files.</p>
</li>
<li><p>Extract the downloaded ZIP to the <code>C:\ExtractedBaselines</code> folder</p>
</li>
<li><p>Install required PowerShell modules: 'GPRegistryPolicyParser', 'BaselineManagement', 'GPRegistryPolicyDsc', 'SecurityPolicyDsc', 'AuditPolicyDsc', 'PSDesiredStateConfiguration', ‘GuestConfiguration’</p>
<ol>
<li><p>Use <strong>PowerShell Core</strong>!</p>
</li>
<li><pre><code class="lang-powershell"> <span class="hljs-comment"># download &amp; import essential modules</span>
 <span class="hljs-string">'GPRegistryPolicyParser'</span>, <span class="hljs-string">'BaselineManagement'</span>, <span class="hljs-string">'GPRegistryPolicyDsc'</span>, <span class="hljs-string">'SecurityPolicyDsc'</span>, <span class="hljs-string">'AuditPolicyDsc'</span>, <span class="hljs-string">'PSDesiredStateConfiguration'</span>, <span class="hljs-string">'GuestConfiguration'</span> | % {
     <span class="hljs-built_in">Install-Module</span> <span class="hljs-variable">$_</span>
 }
 <span class="hljs-comment"># make sure essential modules are in some of the path mentioned in the $env:PSModulePath!</span>
</code></pre>
</li>
</ol>
</li>
<li><p>Extract and organize baseline GPOs for better visibility</p>
<ol>
<li><pre><code class="lang-powershell"> <span class="hljs-comment"># organize GPOs for better visibility</span>
 <span class="hljs-variable">$extractedBaseline</span> = <span class="hljs-string">"C:\ExtractedBaselines"</span>
 <span class="hljs-variable">$gpoBaselineDirectory</span> = <span class="hljs-string">"C:\BaselineGpos"</span>

 <span class="hljs-built_in">Remove-Item</span> <span class="hljs-variable">$gpoBaselineDirectory</span> <span class="hljs-literal">-Recurse</span> <span class="hljs-literal">-Force</span> <span class="hljs-literal">-ErrorAction</span> SilentlyContinue
 [<span class="hljs-built_in">Void</span>][<span class="hljs-type">System.IO.Directory</span>]::CreateDirectory(<span class="hljs-variable">$gpoBaselineDirectory</span>)

 <span class="hljs-keyword">foreach</span> (<span class="hljs-variable">$gpo</span> <span class="hljs-keyword">in</span> (<span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-string">"<span class="hljs-variable">$extractedBaseline</span>\GPOs"</span> <span class="hljs-literal">-Directory</span>)) {
     <span class="hljs-variable">$gpoName</span> = ([<span class="hljs-type">regex</span>]<span class="hljs-string">"&lt;GPODisplayName&gt;&lt;!\[CDATA\[(.+)\]\]&gt;&lt;/GPODisplayName&gt;"</span>).Matches((<span class="hljs-built_in">gc</span> <span class="hljs-string">"<span class="hljs-variable">$</span>(<span class="hljs-variable">$gpo</span>.fullname)\bkupInfo.xml"</span> <span class="hljs-literal">-Raw</span>)).captures.groups[<span class="hljs-number">1</span>].value

     <span class="hljs-built_in">New-Item</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$gpoBaselineDirectory</span> <span class="hljs-literal">-Name</span> <span class="hljs-variable">$gpoName</span> <span class="hljs-literal">-ItemType</span> Directory <span class="hljs-literal">-Force</span>
     <span class="hljs-built_in">Copy-Item</span> <span class="hljs-variable">$gpo</span>.fullname <span class="hljs-string">"<span class="hljs-variable">$gpoBaselineDirectory</span>\<span class="hljs-variable">$gpoName</span>"</span> <span class="hljs-literal">-Recurse</span> <span class="hljs-literal">-Force</span>
 }
</code></pre>
</li>
</ol>
</li>
<li><p>Create DSC configurations (<code>mof</code> and <code>ps1</code>) from the baseline GPOs</p>
<ol>
<li><pre><code class="lang-powershell"> <span class="hljs-comment"># create DSC configuration from the baseline (mof and ps1)</span>
 <span class="hljs-variable">$dscDirectory</span> = <span class="hljs-string">"C:\ConvertedBaselineGpos"</span>

 <span class="hljs-built_in">Remove-Item</span> <span class="hljs-variable">$dscDirectory</span> <span class="hljs-literal">-Recurse</span> <span class="hljs-literal">-Force</span> <span class="hljs-literal">-ErrorAction</span> SilentlyContinue
 [<span class="hljs-built_in">Void</span>][<span class="hljs-type">System.IO.Directory</span>]::CreateDirectory(<span class="hljs-variable">$gpoBaselineDirectory</span>)

 <span class="hljs-built_in">Get-ChildItem</span> <span class="hljs-variable">$gpoBaselineDirectory</span> | % {
     <span class="hljs-variable">$confName</span> = <span class="hljs-variable">$_</span>.Name <span class="hljs-operator">-replace</span> <span class="hljs-string">"-"</span>, <span class="hljs-string">"_"</span> <span class="hljs-operator">-replace</span> <span class="hljs-string">"\s*"</span>
     <span class="hljs-variable">$confName</span>

     <span class="hljs-comment"># !BEWARE! creating of some localhost.mof can (probably will) end with an error https://github.com/microsoft/BaselineManagement?tab=readme-ov-file#known-gaps-in-capability</span>
     <span class="hljs-comment"># problematic ps1 parts have to be commented otherwise you will not be able to create DSC from it!</span>
     <span class="hljs-keyword">try</span> {
         <span class="hljs-variable">$ErrorActionPreference</span> = <span class="hljs-string">'stop'</span>
         <span class="hljs-variable">$convertedGpo</span> = <span class="hljs-built_in">ConvertFrom-GPO</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$_</span>.FullName <span class="hljs-literal">-OutputConfigurationScript</span> <span class="hljs-literal">-OutputPath</span> <span class="hljs-variable">$dscDirectory</span> <span class="hljs-literal">-ConfigName</span> <span class="hljs-variable">$confName</span>
     } <span class="hljs-keyword">catch</span> {
         <span class="hljs-keyword">if</span> (<span class="hljs-variable">$_</span> <span class="hljs-operator">-like</span> <span class="hljs-string">"Invalid MOF definition*"</span>) {
             <span class="hljs-built_in">Write-Warning</span> <span class="hljs-string">"In '<span class="hljs-variable">$</span>(<span class="hljs-variable">$convertedGpo</span>.ConfigurationScript)' comment setting that contains property mentioned in this error '<span class="hljs-variable">$_</span>'.`n`nOtherwise you will not be able to generate guest configuration from it!"</span>
         } <span class="hljs-keyword">else</span> {
             <span class="hljs-built_in">Write-Error</span> <span class="hljs-variable">$_</span>
         }
     }
     <span class="hljs-variable">$ErrorActionPreference</span> = <span class="hljs-string">'continue'</span>

     <span class="hljs-comment"># disable mof compilation part (last line) I will compile it by myself later</span>
     <span class="hljs-variable">$modifiedContent</span> = <span class="hljs-built_in">Get-Content</span> <span class="hljs-variable">$convertedGpo</span>.ConfigurationScript | ? { <span class="hljs-variable">$_</span> <span class="hljs-operator">-notlike</span> <span class="hljs-string">"<span class="hljs-variable">$confName</span> -OutputPath *"</span> }
     <span class="hljs-variable">$modifiedContent</span> <span class="hljs-operator">-join</span> <span class="hljs-string">"`n"</span> | <span class="hljs-built_in">Set-Content</span> <span class="hljs-variable">$convertedGpo</span>.ConfigurationScript <span class="hljs-literal">-Force</span>
 }
</code></pre>
</li>
<li><p>For converting of GPOs to DSC we are using the <a target="_blank" href="https://github.com/microsoft/BaselineManagement">BaselineManagement</a> module. But because DSC has some <a target="_blank" href="https://github.com/microsoft/BaselineManagement?tab=readme-ov-file#known-gaps-in-capability">limitations</a>, we have to comment on some of the settings in the generated <code>ps1</code> DSC scripts, to get working DSC <code>mof</code> file later!</p>
</li>
<li><p>This is also the time when you should modify those <code>ps1</code> files to suit your environmental needs</p>
</li>
</ol>
</li>
<li><p>Now when we have the final <code>ps1</code> DSC scripts, we can generate DSC <code>mof</code> files from them (if you want to use them outside of Azure ARC)</p>
<ol>
<li><pre><code class="lang-powershell"> <span class="hljs-comment"># generate MOF file </span>
 <span class="hljs-comment"># dot source the configuration</span>
 . &lt;pathToGeneratedPs1Script&gt;
 <span class="hljs-comment"># call the configuration</span>
 &lt;nameOfTheConfigurationStoredInPs1Script&gt;
 <span class="hljs-comment"># result will be localhost.mof file</span>
</code></pre>
</li>
</ol>
</li>
<li><p>Or we can generate Guest Configuration packages. There is <a target="_blank" href="https://learn.microsoft.com/en-us/azure/governance/machine-configuration/how-to/develop-custom-package/overview">official documentation</a> regarding this topic so just a simplified overview</p>
<ol>
<li><pre><code class="lang-powershell"> <span class="hljs-comment"># created ps1 DSC scripts saved in $dscDirectory have to be converted to guest configuration packages now</span>
 <span class="hljs-comment"># https://learn.microsoft.com/en-us/azure/governance/machine-configuration/how-to/develop-custom-package/overview</span>

 <span class="hljs-comment"># generate guest configuration package</span>
 <span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
     Name             = <span class="hljs-variable">$guestConfigurationName</span>
     Path             = <span class="hljs-variable">$guestConfigTempDirectory</span>
     Configuration    = <span class="hljs-string">"<span class="hljs-variable">$guestConfigTempDirectory</span>\localhost.mof"</span>
     FrequencyMinutes = <span class="hljs-number">15</span>
     <span class="hljs-built_in">Type</span>             = <span class="hljs-variable">$definedGuestConfigurationType</span> <span class="hljs-comment"># AuditAndSet, Audit</span>
     FilesToInclude   = <span class="hljs-string">'&lt;pathTo_AuditPolicyDsc_Module&gt;'</span>, <span class="hljs-string">'&lt;pathTo_GPRegistryPolicyDsc_Module&gt;'</span>, <span class="hljs-string">'&lt;pathTo_SecurityPolicyDsc_Module&gt;'</span> <span class="hljs-comment"># modules required by generated package to work correctly on destination machine</span>
     Force            = <span class="hljs-variable">$true</span>
 }
 <span class="hljs-string">"Generate guest configuration package '<span class="hljs-variable">$guestConfigurationName</span>'"</span>
 <span class="hljs-built_in">New-GuestConfigurationPackage</span> @<span class="hljs-keyword">param</span> <span class="hljs-literal">-ErrorAction</span> <span class="hljs-keyword">continue</span>

 <span class="hljs-comment"># Upload package to Azure storage</span>
 <span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
     Container = <span class="hljs-variable">$guestConfigContainerName</span>
     File      = <span class="hljs-variable">$guestConfigFile</span>
     Force     = <span class="hljs-variable">$true</span>
     Blob      = <span class="hljs-variable">$policyBlobName</span>
 }

 <span class="hljs-string">"Upload blob to Azure Storage"</span>
 <span class="hljs-variable">$guestConfigBlob</span> = <span class="hljs-built_in">Set-AzStorageBlobContent</span> @<span class="hljs-keyword">param</span>

 <span class="hljs-comment"># set package SAS URL</span>
 <span class="hljs-variable">$param</span> = <span class="hljs-selector-tag">@</span>{
     StartTime  = <span class="hljs-variable">$startTime</span>
     ExpiryTime = <span class="hljs-variable">$endTime</span>
     Container  = <span class="hljs-variable">$guestConfigContainerName</span>
     Blob       = <span class="hljs-variable">$policyBlobName</span>
     Permission = <span class="hljs-string">'r'</span>
     Context    = <span class="hljs-variable">$storageAccount</span>.Context
     FullUri    = <span class="hljs-variable">$true</span>
 }
 <span class="hljs-string">"Set blob SAS Url"</span>
 <span class="hljs-variable">$contentUri</span> = <span class="hljs-built_in">New-AzStorageBlobSASToken</span> @<span class="hljs-keyword">param</span>

 <span class="hljs-comment"># generate Azure policy template of guest configuration type locally</span>
 <span class="hljs-variable">$policyConfig</span> = <span class="hljs-selector-tag">@</span>{
     PolicyId      = (<span class="hljs-built_in">New-Guid</span>).guid
     ContentUri    = <span class="hljs-variable">$contentUri</span>
     DisplayName   = <span class="hljs-variable">$guestConfigurationName</span>
     Description   = <span class="hljs-variable">$description</span>
     Path          = <span class="hljs-variable">$guestConfigTempDirectory</span>
     Platform      = <span class="hljs-string">'Windows'</span>
     Mode          = <span class="hljs-variable">$azurePolicyType</span> <span class="hljs-comment"># ApplyAndMonitor, ApplyAndAutoCorrect, Audit</span>
     PolicyVersion = <span class="hljs-variable">$newPolicyVersion</span>.tostring()
 }

 <span class="hljs-string">"Generate locally Azure policy template of the 'Guest Configuration' type ('<span class="hljs-variable">$azurePolicyType</span>' ver. <span class="hljs-variable">$</span>(<span class="hljs-variable">$newPolicyVersion</span>.tostring()))"</span>
 <span class="hljs-built_in">New-GuestConfigurationPolicy</span> @policyConfig
</code></pre>
</li>
</ol>
</li>
</ol>
]]></content:encoded></item><item><title><![CDATA[Automated Software Vulnerability Notification]]></title><description><![CDATA[Today, I’ll guide you through setting up an automation system that notifies your users about vulnerable software detected on their devices. This information will be sourced from the Microsoft Defender API.
In this post, I’ll demonstrate how to create...]]></description><link>https://doitpshway.com/automated-software-vulnerability-notification</link><guid isPermaLink="true">https://doitpshway.com/automated-software-vulnerability-notification</guid><category><![CDATA[Azure]]></category><category><![CDATA[vulnerability]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[automation]]></category><category><![CDATA[Defender for Endpoint]]></category><category><![CDATA[azure runbook]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 02 Oct 2024 11:39:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1729081051602/1ccc79be-79ef-41d3-ad68-10bafa6bbe7e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Today, I’ll guide you through setting up an automation system that notifies your users about vulnerable software detected on their devices. This information will be sourced from the Microsoft Defender API.</p>
<p>In this post, I’ll demonstrate how to create this automation within your Azure tenant and provide all the necessary PowerShell code.</p>
<p>Below is an example of the email your user will receive if a vulnerable <strong>.NET</strong> app is found on their device 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729081016818/2d057f14-9e9d-41ee-b6c7-9f3434c55403.png" alt class="image--center mx-auto" /></p>
<p>As shown, the email includes a list of vulnerable apps, their versions, clickable CVEs, and details on how each app was detected.</p>
<h2 id="heading-but-why-should-we-make-users-update-their-apps-in-the-first-place">But why should we make users update their apps in the first place?</h2>
<ul>
<li><p><strong>Users can install software on their own avoiding any interruptions</strong></p>
</li>
<li><p><strong>Automatic update of the app can cause compatibility issues</strong></p>
<ul>
<li>this applies mostly to developer tools like Visual Studio, Python, .Net, NodeJS, etc</li>
</ul>
</li>
</ul>
<p>These are some of the reasons why involving your users in the app update process can be necessary.</p>
<p>But just informing the users without any sort of "punishment" wouldn't be sufficient. That is the reason why we are sending a second email in case the user doesn't fix the issue in time. This second email can be sent to the user manager, security team, etc. Or another more brutal approach can be chosen like isolating the device 😎.</p>
<hr />
<h1 id="heading-summary">Summary</h1>
<p>After you finish this tutorial you will have:</p>
<ul>
<li><p>Azure Automation that will monitor vulnerabilities on company devices and send email notifications based on the set thresholds to the device owner</p>
<ul>
<li><p>first email by default 30 days after the first vuln. detection</p>
<ul>
<li>TIP: A list of apps that should trigger immediate notification is supported</li>
</ul>
</li>
<li><p>second email by default 14 days after the first one</p>
</li>
</ul>
</li>
<li><p>(optional) Azure KeyVault that will safely store SendGrid API key</p>
</li>
<li><p>Azure Storage blob that will be used as persistent storage for Azure Automation runbook</p>
</li>
</ul>
<hr />
<h1 id="heading-prerequisites">Prerequisites</h1>
<ul>
<li><p>License for '<strong>Microsoft Defender Vulnerability Management</strong>'</p>
<ul>
<li><a target="_blank" href="https://learn.microsoft.com/en-us/defender-vulnerability-management/defender-vulnerability-management-faq#defender-vulnerability-management-licensing-faqs">Official licensing FAQ</a></li>
</ul>
</li>
<li><p><strong>Permission</strong> to create Azure Automation and assign required <a target="_blank" href="https://learn.microsoft.com/en-us/defender-endpoint/api/exposed-apis-list">Microsoft Defender for Endpoint API</a> permissions</p>
</li>
<li><p>Willing to <strong>pay 1$/month</strong> for required Azure services (Storage, Automation, and KeyVault)</p>
<ul>
<li>Or rewrite the code and host it on-premises for free</li>
</ul>
</li>
<li><p><strong>SendGrid</strong> account (API token) for sending notification emails</p>
<ul>
<li>Runbook code can be customized to use any SMTP service, sending Teams messages, etc.</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-lets-go">Let's go</h1>
<h2 id="heading-create-automation-account">Create Automation Account</h2>
<p>Create a new Automation Account named <strong>VulnerableApps</strong>.</p>
<p>Make a note of the <strong>ID</strong> of the automatically created System Managed Identity. You will need it later when granting permission.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1717173722311/a76cebe8-24e9-43a7-860f-1b6e1c70f474.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-create-a-runbook">Create a Runbook</h3>
<p>Inside the Automation Account create a new PowerShell 5.1 Runbook and copy-paste code from my <a target="_blank" href="https://github.com/ztrhgf/azure_automation_runbooks/blob/main/VulnerableApps.ps1">VulnerableApps.ps1 script</a></p>
<p>Now <strong>modify</strong> <code>#region CORE variables</code> section <strong>to suit your environment</strong> and <strong>save</strong> the result!</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💥</div>
<div data-node-type="callout-text"><strong>This is vital for it to work so read carefully the comments above the variables and set the correct values</strong></div>
</div>

<p><code>$sendGridParam</code> has to contain values set in the section <strong>Create Azure KeyVault for storing SendGrid API key.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1717173465149/a1990c04-89c7-42d0-87aa-1c3e2b8d1c58.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-grant-required-permissions-for-accessing-microsoft-defender-api">Grant required permissions for accessing Microsoft Defender API</h3>
<p>In order for our automation to be able to read data from the Microsoft Defender API, it is necessary to assign it the following <strong>WindowsDefenderATP</strong> permissions:</p>
<ul>
<li><p><code>User.Read.All</code></p>
</li>
<li><p><code>SecurityRecommendation.Read.All</code></p>
</li>
<li><p><code>Alert.Read.All</code></p>
</li>
<li><p><code>Software.Read.All</code></p>
</li>
<li><p><code>Vulnerability.Read.All</code></p>
</li>
<li><p><code>Machine.Read.All</code></p>
</li>
<li><p><code>AdvancedQuery.Read.All</code></p>
</li>
</ul>
<p>You can do it easily using my function <code>Grant-AzureServicePrincipalPermission</code> (part of the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureApplicationStuff">AzureApplicationStuff module</a>) or manually in the Azure portal</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># example code, you need to adjust it to match your environment!</span>

<span class="hljs-built_in">Connect-AzAccount</span> 

<span class="hljs-built_in">Grant-AzureServicePrincipalPermission</span> <span class="hljs-literal">-servicePrincipalId</span> <span class="hljs-string">'yourManagedIdentityId'</span> <span class="hljs-literal">-permissionType</span> application <span class="hljs-literal">-permissionList</span> <span class="hljs-string">'User.Read.All'</span>,<span class="hljs-string">'SecurityRecommendation.Read.All'</span>,<span class="hljs-string">'Alert.Read.All'</span>,<span class="hljs-string">'Software.Read.All'</span>,<span class="hljs-string">'Vulnerability.Read.All'</span>,<span class="hljs-string">'Machine.Read.All'</span>,<span class="hljs-string">'AdvancedQuery.Read.All'</span> <span class="hljs-literal">-resourceAppId</span> <span class="hljs-string">'fc780465-2017-40d4-a0c5-307022471b92'</span>
</code></pre>
<h2 id="heading-import-required-modules">Import required modules</h2>
<p>Runbook code depends on several other modules that I've created, so in order for it to work, we need to import them into our Automation Account environment.</p>
<p>Required modules:</p>
<ul>
<li><p><code>AzureResourceStuff</code></p>
</li>
<li><p><code>M365DefenderStuff</code></p>
</li>
<li><p><code>CommonStuff</code></p>
</li>
<li><p><code>PSSendGrid</code></p>
</li>
</ul>
<p>You can do it easily using my function <code>New-AzureAutomationModule</code> or <code>New-AzureAutomationRuntimeModule</code> in case you are using Runtime Environments (both functions are part of the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff">AzureResourceStuff module</a>) which has a huge benefit of importing but just required modules, but also their required dependencies 👍.</p>
<p>Or you can import the modules manually in the Automation account settings.</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># example code, you need to adjust it to match your environment!</span>

<span class="hljs-built_in">Connect-AzAccount</span> <span class="hljs-literal">-Tenant</span> <span class="hljs-string">"contoso.onmicrosoft.com"</span> <span class="hljs-literal">-SubscriptionName</span> <span class="hljs-string">"AutomationSubscription"</span>

<span class="hljs-string">'AzureResourceStuff'</span>,<span class="hljs-string">'M365DefenderStuff'</span>,<span class="hljs-string">'CommonStuff'</span>,<span class="hljs-string">'PSSendGrid'</span> | % {
    <span class="hljs-built_in">New-AzureAutomationModule</span> <span class="hljs-literal">-resourceGroupName</span> XXX <span class="hljs-literal">-automationAccountName</span> YYY <span class="hljs-literal">-moduleName</span> <span class="hljs-variable">$_</span>
}
</code></pre>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">For more details on how <code>New-AzureAutomationModule</code> works, check <a target="_blank" href="https://doitpshway.com/import-new-or-update-existing-powershell-module-including-its-dependencies-into-azure-automation-account-using-powershell">this article</a></div>
</div>

<h2 id="heading-create-an-azure-storage-account-for-storing-runbook-persistent-data">Create an Azure Storage Account for storing Runbook persistent data</h2>
<p>Because Azure Automation Runbook doesn't have the option to store complex persistent data, we need to store such data in Azure Blob storage.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">For more details about creating an Azure Storage Account for storing Runbook variables check <a target="_blank" href="https://doitpshway.com/create-persistent-azure-automation-runbook-variables-using-azure-blob-storage">this article</a></div>
</div>

<p>In the subscription where you host your Runbook please create the following structure:</p>
<ul>
<li><p>Resource Group Name named <code>PersistentRunbookVariables</code></p>
<ul>
<li><p>In that group create a Storage Account named <code>persistentvariablesstore</code></p>
<ul>
<li>In that Storage Account create a container named <code>variables</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">You can choose your own names, but in such case, you will have to modify parameters when calling my <code>Export-VariableToStorage</code> and <code>Import-VariableFromStorage</code> functions</div>
</div>

<h3 id="heading-grant-runbook-managed-identity-permissions-to-the-azure-storage-account-container">Grant Runbook Managed Identity permissions to the Azure Storage Account container</h3>
<p>Grant our Managed Identity <code>Storage Blob Data Contributor</code> IAM role over Storage Account container created in the previous step. So it can modify files in this container.</p>
<h2 id="heading-create-azure-keyvault-for-storing-sendgrid-api-key">Create Azure KeyVault for storing SendGrid API key</h2>
<p>For sending emails via SendGrid I am using my function <code>Send-EmailViaSendGrid</code>. This function supports retrieval of the SendGrid API key directly from Azure KeyVault, which is an ideal solution for Azure Automations.</p>
<p>The only thing you have to do is to create such a secret in the existing KeyVault (or some newly created one) and set <code>$sendGridParam</code> hashtable values accordingly in your Runbook code.</p>
<h3 id="heading-grant-runbook-managed-identity-permissions-to-read-stored-sendgrid-token">Grant Runbook Managed Identity permissions to read stored SendGrid token</h3>
<p>Grant our Managed Identity <code>Key Vault Secrets User</code> IAM role over the created secret to allow it to read it.</p>
<hr />
<h1 id="heading-gotchas">Gotchas</h1>
<ul>
<li><p>Defender vulnerability report doesn't have to be 100% accurate, because if app is incorrectly uninstalled therefore some registry keys or files remain on the device disk, Defender can still detect it even though the app isn't installed. In such rare cases, you need to manually remove some leftovers.</p>
</li>
<li><p>If you use MDT for software installation, apps are installed under a built-in administrator account, which can lead to situations where the vulnerable app is detected in the built-in administrator profile/registry! This can be a significant problem for your users because it can be hard for them to remove such leftover data.</p>
<ul>
<li><p>This can be solved by removing the built-in admin profile (not the account, just the profile data). For example, using Intune remediation script and PSH code</p>
<pre><code class="lang-powershell">  <span class="hljs-built_in">Get-CimInstance</span> <span class="hljs-literal">-Class</span> Win32_UserProfile | ? { <span class="hljs-variable">$_</span>.SID <span class="hljs-operator">-like</span> <span class="hljs-string">"*-500"</span> } | <span class="hljs-built_in">Remove-CimInstance</span>
</code></pre>
</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-tips">Tips</h1>
<ul>
<li><p><strong>Inform your users</strong> about those notifications beforehand! So they don't consider them as a scam 😀</p>
</li>
<li><p>You can <strong>exclude the specific application</strong> from this notification automation, for example, because it is the last existing version, so your users cannot update it anyway</p>
<ul>
<li><p>Just update the <code>$exclusionList</code> variable in the automation code</p>
<pre><code class="lang-powershell">  <span class="hljs-comment">&lt;#
  - 'CveId' and/or 'ProductName' property/ies (string) have to be defined
  - 'ProductVersion' (string) is optional
      - property values can be extracted from $vulnerabilityPerMachine.vulnswdata
  - 'ValidUntil' (datetime) is optional (since this date, exclustion will be ignored)
      - BEWARE that in Azure pipeline EN date has to be used a.k.a. month.day.year!!!
  #&gt;</span>

  <span class="hljs-variable">$exclusionList</span> = <span class="hljs-selector-tag">@</span>(
      [<span class="hljs-type">PSCustomObject</span>]<span class="hljs-selector-tag">@</span>{
          CveId       = <span class="hljs-string">'CVE-2024-32002'</span>
          ProductName = <span class="hljs-string">'visual_studio_2022'</span>
          ValidUntil  = (<span class="hljs-built_in">Get-Date</span> <span class="hljs-number">8.1</span>.<span class="hljs-number">2024</span>) <span class="hljs-comment"># M.d.yyyy</span>
      }
  )
</code></pre>
</li>
</ul>
</li>
<li><p>Check <a target="_blank" href="https://doitpshway.com/gradual-update-of-all-applications-using-winget-and-custom-azure-ring-groups">this article</a> if you want to update all installed applications gradually using Winget</p>
<ul>
<li>exceptions can be made</li>
</ul>
</li>
<li><p>If your helpdesk interactively logs in to employees' computers, you can avoid identifying such accounts as device owners by altering the following line of code in the runbook</p>
</li>
<li><pre><code class="lang-powershell">        <span class="hljs-variable">$user</span> = <span class="hljs-built_in">Get-M365DefenderMachineUser</span> <span class="hljs-literal">-header</span> <span class="hljs-variable">$header</span> <span class="hljs-literal">-machineId</span> <span class="hljs-variable">$machineId</span> | ? { <span class="hljs-variable">$_</span>.logonTypes <span class="hljs-operator">-like</span> <span class="hljs-string">'*Interactive*'</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$_</span>.accountName <span class="hljs-operator">-ne</span> <span class="hljs-string">"administrator"</span> }
</code></pre>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Manage Microsoft 365 Defender (XDR) via PowerShell]]></title><description><![CDATA[In case you are using Microsoft Defender you are familiar with the security.microsoft.com portal. You also probably know that Microsoft also offers API for this security solution.
Today I will show you some of my PowerShell commands (M365DefenderStuf...]]></description><link>https://doitpshway.com/manage-microsoft-365-defender-xdr-via-powershell</link><guid isPermaLink="true">https://doitpshway.com/manage-microsoft-365-defender-xdr-via-powershell</guid><category><![CDATA[Defender for Endpoint]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[modules]]></category><category><![CDATA[APIs]]></category><category><![CDATA[xdr]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 12 Jun 2024 09:59:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1718186236712/9de916b7-fed1-404b-a125-76fe156ce9f4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In case you are using Microsoft Defender you are familiar with the <a target="_blank" href="http://security.microsoft.com">security.microsoft.com</a> portal. You also probably know that Microsoft also offers <a target="_blank" href="https://learn.microsoft.com/en-us/defender-endpoint/api/exposed-apis-list">API</a> for this security solution.</p>
<p>Today I will show you some of my PowerShell commands (<a target="_blank" href="https://www.powershellgallery.com/packages/M365DefenderStuff/1.0.0">M365DefenderStuff module</a>) with a focus on the '<strong>Microsoft Defender Vulnerability Management</strong>' part.</p>
<p>The main benefit of using my module instead of direct API calls is built-in support for <strong>pagination</strong>, <strong>throttling</strong>, <strong>time-outs</strong>, and other nasty things 😎.</p>
<hr />
<h1 id="heading-prerequisites">Prerequisites</h1>
<ul>
<li><p>In general license for using Microsoft Defender solution</p>
</li>
<li><p>Special license for using '<strong>Microsoft Defender Vulnerability Management</strong>' in case you want to see found vulnerabilities on your clients</p>
<ul>
<li><a target="_blank" href="https://learn.microsoft.com/en-us/defender-vulnerability-management/defender-vulnerability-management-faq#defender-vulnerability-management-licensing-faqs">Official licensing FAQ</a></li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-before-we-begin">Before we begin</h1>
<h2 id="heading-install-m365defenderstuff-module"><strong>Install</strong> M365DefenderStuff module</h2>
<p>To be able to use my PowerShell commands, you must first install the <a target="_blank" href="https://www.powershellgallery.com/packages/M365DefenderStuff/1.0.0">M365DefenderStuff</a> module from the PowerShell Gallery.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Install-Module</span> M365DefenderStuff
</code></pre>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">TIP: to get all available commands run the following command in your PowerShell console: <code>Get-Command -module M365DefenderStuff</code></div>
</div>

<hr />
<h1 id="heading-control-your-m365-defender-via-powershell"><strong>Control your M365 Defender via PowerShell</strong></h1>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">TIP: to be able to use specific API, you need to have correct permissions. What permissions are needed is stated in the NOTES section of each function (<code>Get-Help &lt;functionname&gt; -Full</code>).</div>
</div>

<h2 id="heading-authenticate"><strong>Authenticate</strong></h2>
<pre><code class="lang-powershell"><span class="hljs-built_in">Import-Module</span> Az.Accounts

<span class="hljs-built_in">Connect-AzAccount</span>
</code></pre>
<h2 id="heading-get-allselected-machine">Get all/selected machine</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get all machines</span>
<span class="hljs-built_in">Get-M365DefenderMachine</span>

<span class="hljs-comment"># get just specific machine</span>
<span class="hljs-built_in">Get-M365DefenderMachine</span> <span class="hljs-literal">-machineId</span> <span class="hljs-string">'fff5e5e26cb0848d66bbb0bc83de6bceb4a1b2e1'</span>
</code></pre>
<h2 id="heading-get-the-machine-owner-the-user-who-logs-in">Get the machine owner (the user who logs in)</h2>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-M365DefenderMachineUser</span> <span class="hljs-literal">-machineId</span> <span class="hljs-string">'fff5e5e26cb0848d66bbb0bc83de6bceb4a1b2e1'</span>
</code></pre>
<h2 id="heading-get-detected-software">Get detected software</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get all detected applications</span>
<span class="hljs-built_in">Get-M365DefenderSoftware</span>

<span class="hljs-comment"># get just specific application</span>
<span class="hljs-built_in">Get-M365DefenderSoftware</span> <span class="hljs-literal">-softwareId</span> <span class="hljs-string">'adobe-_-creative_cloud'</span>
</code></pre>
<h2 id="heading-get-detected-vulnerabilities">Get detected vulnerabilities</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get all found vulnerabilities (can take several minutes to complete!)</span>
<span class="hljs-built_in">Get-M365DefenderVulnerability</span>

<span class="hljs-comment"># get details of specific vulnerability</span>
<span class="hljs-built_in">Get-M365DefenderVulnerability</span> <span class="hljs-literal">-vulnerabilityId</span> <span class="hljs-string">'CVE-2022-47926'</span>
</code></pre>
<h2 id="heading-generate-vulnerability-report">Generate vulnerability report</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get just software vulnerabilities of CTRITICAL type and group them by machine</span>
<span class="hljs-built_in">Get-M365DefenderVulnerabilityReport</span> <span class="hljs-literal">-groupBy</span> machine <span class="hljs-literal">-skipOSVuln</span> <span class="hljs-literal">-severity</span> Critical
</code></pre>
<h2 id="heading-invoke-kql-query">Invoke KQL query</h2>
<pre><code class="lang-powershell"><span class="hljs-built_in">Invoke-M365DefenderAdvancedQuery</span> <span class="hljs-literal">-query</span> <span class="hljs-string">"DeviceInfo | join kind = fullouter DeviceTvmSoftwareEvidenceBeta on DeviceId"</span>
</code></pre>
<h2 id="heading-get-software-evidence">Get software evidence</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get all (100 000 at most) applications evidences</span>
<span class="hljs-built_in">Invoke-M365DefenderSoftwareEvidenceQuery</span>

<span class="hljs-comment"># get all (100 000 at most) applications evidences related to JRE software</span>
<span class="hljs-built_in">Invoke-M365DefenderSoftwareEvidenceQuery</span> <span class="hljs-literal">-appName</span> JRE
</code></pre>
<h2 id="heading-get-defender-recommendations">Get defender recommendations</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># get all security recommendations</span>
<span class="hljs-built_in">Get-M365DefenderRecommendation</span>

<span class="hljs-comment"># get security recommendations just for Putty software.</span>
<span class="hljs-built_in">Get-M365DefenderRecommendation</span> <span class="hljs-literal">-productName</span> <span class="hljs-string">'putty'</span>

<span class="hljs-comment"># get all security recommendations for given machine.</span>
<span class="hljs-built_in">Get-M365DefenderRecommendation</span> <span class="hljs-literal">-machineId</span> <span class="hljs-string">'43a802402664e76a021c8dda2e2aa7db6a09a5f1'</span>
</code></pre>
<h2 id="heading-get-all-vulnerabilities-per-machine-and-software">Get all vulnerabilities per machine and software</h2>
<pre><code class="lang-powershell"><span class="hljs-comment"># retrieves a list of all the vulnerabilities affecting the organization per machine and software.</span>
<span class="hljs-built_in">Get-M365DefenderMachineVulnerability</span>
</code></pre>
<p>That is all for now, but more functions will be added in the future to the <a target="_blank" href="https://www.powershellgallery.com/packages/M365DefenderStuff/1.0.0">M365DefenderStuff module</a> don't worry 😉</p>
]]></content:encoded></item><item><title><![CDATA[Managing Azure Automation Runtime Environments via PowerShell]]></title><description><![CDATA[You may have heard about a new (currently in preview) feature called Runtime Environment.
The main features are:

You can have multiple custom runtime environments that can be used across numerous Runbooks (a.k.a. one runtime in multiple runbooks)

T...]]></description><link>https://doitpshway.com/managing-azure-automation-runtime-environments-via-powershell</link><guid isPermaLink="true">https://doitpshway.com/managing-azure-automation-runtime-environments-via-powershell</guid><category><![CDATA[Azure]]></category><category><![CDATA[automation]]></category><category><![CDATA[azure runbook]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 12 Jun 2024 07:57:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1718178874989/b2867440-95b2-4bd9-91b5-2e496cfb63bf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You may have heard about a new (currently in preview) feature called <a target="_blank" href="https://learn.microsoft.com/en-us/azure/automation/runtime-environment-overview">Runtime Environment</a>.</p>
<p>The main features are:</p>
<ul>
<li><p>You can have multiple custom runtime environments that can be used across numerous Runbooks (a.k.a. one runtime in multiple runbooks)</p>
</li>
<li><p>Testing of new module versions is super easy, you create a new runtime, import modules you want to test, switch to this runtime in your runbook, and see how it goes</p>
</li>
<li><p>Testing of the new Runtime language version is super easy</p>
</li>
<li><p>All of this can be automated via direct API calls</p>
</li>
</ul>
<p><strong>Today I will show you how to manage the whole Runtime Environment lifecycle including modules management through my PowerShell module</strong> <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff"><strong>AzureResourceStuff</strong></a><strong>.</strong></p>
<p>By the way, there is <a target="_blank" href="https://learn.microsoft.com/en-us/azure/automation/manage-runtime-environment?tabs=create-runtime-rest%2Clist-runtime-portal%2Cdelete-runtime-portal%2Cupdate-runtime-rest%2Ccreate-runbook-portal%2Cupdate-runbook-portal%2Ctest-update-api%2Ccreate-cloud-job-portal">official documentation</a> about managing Runtime Environments via API, but it lacks a lot of information, therefore I had to use web browser Developer Tools (F12) to get what I needed in most cases 😎</p>
<hr />
<h1 id="heading-before-we-begin">Before we begin</h1>
<h2 id="heading-enable-runtime-environments-preview">Enable Runtime Environments (Preview)</h2>
<p>Open your testing Automation Account in the Azure web portal interface.</p>
<p>Manually <a target="_blank" href="https://learn.microsoft.com/en-us/azure/automation/runtime-environment-overview#switch-between-new-and-old-experience">switch to the new Runtime experience</a> before you continue!</p>
<p>By the way, you can switch back using <code>Switch to Old Experience</code> button any time.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1718176572854/b6fd9480-ce9f-4e5d-b123-19792d274fc7.png" alt class="image--center mx-auto" /></p>
<p>Now when switching to the new experience, you should see a new menu <code>Runtime Environments (Preview)</code> in your Automation Account left pane.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1718176443948/0585b6c3-9645-4a68-a46b-04a8c7721870.png" alt class="image--center mx-auto" /></p>
<p>Now, we can create new runtimes, import modules, and more manually or using PowerShell commands, as shown below.</p>
<h2 id="heading-install-azureresourcestuff-module">Install AzureResourceStuff module</h2>
<p>To be able to use my PowerShell commands, you must first install <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff">AzureResourceStuff</a> module from the PowerShell Gallery.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Install-Module</span> AzureResourceStuff
</code></pre>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">TIP: to get all commands related to Runtime Environment run the following command in your PowerShell console: <code>Get-Command -Name <em>automationRuntime</em> -module AzureResourceStuff</code></div>
</div>

<hr />
<h1 id="heading-runtime-environment-functions">Runtime environment functions</h1>
<p>Now I show you a few basic actions you want to make with your Runtimes. Be sure to check functions help (via <code>Get-Help</code>) to get more details and examples though!</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Before you begin, make sure you are authenticated to your Azure and that the correct subscription where your testing Automation Account is placed is selected</div>
</div>

<pre><code class="lang-powershell"><span class="hljs-built_in">Import-Module</span> Az.Accounts

<span class="hljs-built_in">Connect-AzAccount</span>

<span class="hljs-built_in">Set-AzContext</span> <span class="hljs-literal">-Subscription</span> <span class="hljs-string">"&lt;nameOfYourSubscription&gt;"</span>
</code></pre>
<h3 id="heading-get-all-runtime-environments">Get all Runtime Environments</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-AzureAutomationRuntime</span>
</code></pre>
<h3 id="heading-create-a-new-runtime-environment">Create a new Runtime Environment</h3>
<pre><code class="lang-powershell"><span class="hljs-variable">$defaultPackage</span> = <span class="hljs-selector-tag">@</span>{
    az = <span class="hljs-string">'8.0.0'</span>
}
<span class="hljs-built_in">New-AzureAutomationRuntime</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span> <span class="hljs-literal">-runtimeLanguage</span> <span class="hljs-string">'PowerShell'</span> <span class="hljs-literal">-runtimeVersion</span> <span class="hljs-string">'7.2'</span> <span class="hljs-literal">-defaultPackage</span> <span class="hljs-variable">$defaultPackage</span>
</code></pre>
<h3 id="heading-add-a-custom-psh-module">Add a custom PSH module</h3>
<p>Custom modules are imported from PSH Gallery or a ZIP file.</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># import newest version of the 'CommonStuff' module (including all required dependencies) from the PowerShell Gallery</span>
<span class="hljs-built_in">New-AzureAutomationRuntimeModule</span> <span class="hljs-literal">-moduleName</span> <span class="hljs-string">'CommonStuff'</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>

<span class="hljs-comment"># import module archived in the selected zip file</span>
<span class="hljs-built_in">New-AzureAutomationRuntimeZIPModule</span> <span class="hljs-literal">-moduleZIPPath</span> <span class="hljs-string">"C:\DATA\helperFunctions.zip"</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>
</code></pre>
<h3 id="heading-update-a-custom-psh-module">Update a custom PSH module</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># update 'CommonStuff' module to the newest version</span>
<span class="hljs-built_in">Update-AzureAutomationRunbookModule</span> <span class="hljs-literal">-moduleName</span> <span class="hljs-string">'CommonStuff'</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>

<span class="hljs-comment"># update/downgrade 'CommonStuff' module to the specified version</span>
<span class="hljs-built_in">Update-AzureAutomationRunbookModule</span> <span class="hljs-literal">-moduleName</span> <span class="hljs-string">'CommonStuff'</span> <span class="hljs-literal">-moduleVersion</span> <span class="hljs-string">'1.0.15'</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>

<span class="hljs-comment"># update all custom modules to their newest version</span>
<span class="hljs-built_in">Update-AzureAutomationRunbookModule</span> <span class="hljs-literal">-allCustomModule</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>

<span class="hljs-comment"># replace existing module with the one saved in selected zip file</span>
<span class="hljs-built_in">New-AzureAutomationRuntimeZIPModule</span> <span class="hljs-literal">-moduleZIPPath</span> <span class="hljs-string">"C:\DATA\helperFunctionsv2.zip"</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>
</code></pre>
<h3 id="heading-remove-a-custom-psh-module">Remove a custom PSH module</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Remove-AzureAutomationRuntimeModule</span> <span class="hljs-literal">-moduleName</span> <span class="hljs-string">'CommonStuff'</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>
</code></pre>
<h3 id="heading-get-available-default-modules">Get available default modules</h3>
<p>Default modules are pre-built into every runtime. You select whether you want to use it (and which version) or don’t want to use it at all.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-AzureAutomationRuntimeAvailableDefaultModule</span>
</code></pre>
<h3 id="heading-set-default-module-version">Set default module version</h3>
<p>The default (built-in) modules are <code>az</code>, <code>azure cli</code> a.k.a. the ones selected from the dropdown menu in the Azure portal GUI.</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$defaultPackage</span> = <span class="hljs-selector-tag">@</span>{
    <span class="hljs-string">'azure cli'</span> = <span class="hljs-string">'2.56.0'</span>
}

<span class="hljs-comment"># replace existing default modules with new setting (remove 'az' completely)</span>
<span class="hljs-built_in">Set-AzureAutomationRuntimeDefaultModule</span> <span class="hljs-literal">-defaultPackage</span> <span class="hljs-variable">$defaultPackage</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span> <span class="hljs-operator">-replace</span>


<span class="hljs-variable">$defaultPackage</span> = <span class="hljs-selector-tag">@</span>{
    <span class="hljs-string">'az'</span>        = <span class="hljs-string">'8.3.0'</span>
    <span class="hljs-string">'azure cli'</span> = <span class="hljs-string">'2.56.0'</span>
}

<span class="hljs-comment"># replace existing default modules with new setting</span>
<span class="hljs-built_in">Set-AzureAutomationRuntimeDefaultModule</span> <span class="hljs-literal">-defaultPackage</span> <span class="hljs-variable">$defaultPackage</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>


<span class="hljs-comment"># remove all default modules</span>
<span class="hljs-built_in">Set-AzureAutomationRuntimeDefaultModule</span> <span class="hljs-literal">-defaultPackage</span> <span class="hljs-selector-tag">@</span>{} <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>
</code></pre>
<h3 id="heading-get-runtime-selected-default-modules">Get Runtime selected default modules</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-AzureAutomationRuntimeSelectedDefaultModule</span>
</code></pre>
<h3 id="heading-make-runtime-environment-copy">Make Runtime Environment copy</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Copy-AzureAutomationRuntime</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">"CustomPSH_7.2"</span> <span class="hljs-literal">-newRuntimeName</span> <span class="hljs-string">"CustomPSH_7.2_v2"</span>
</code></pre>
<h3 id="heading-remove-runtime-environment">Remove Runtime Environment</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Remove-AzureAutomationRuntime</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">'CustomPSH_7.2'</span>
</code></pre>
<hr />
<h1 id="heading-runbook-functions">Runbook functions</h1>
<p>Here are some of the functions related to Runbooks</p>
<h3 id="heading-get-runbook-script-content">Get Runbook script content</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-AzureAutomationRunbookContent</span> <span class="hljs-literal">-runbookName</span> someRunbook <span class="hljs-literal">-ResourceGroupName</span> Automations <span class="hljs-literal">-AutomationAccountName</span> someAutomationAccount
</code></pre>
<h3 id="heading-set-runbook-script-content">Set Runbook script content</h3>
<pre><code class="lang-powershell"><span class="hljs-variable">$content</span> = <span class="hljs-string">@'
   Get-process notepad
   restart-service spooler
'@</span>

<span class="hljs-built_in">Set-AzureAutomationRunbookContent</span> <span class="hljs-literal">-runbookName</span> someRunbook <span class="hljs-literal">-ResourceGroupName</span> Automations <span class="hljs-literal">-AutomationAccountName</span> someAutomationAccount <span class="hljs-literal">-content</span> <span class="hljs-variable">$content</span>
</code></pre>
<h3 id="heading-get-runtime-used-in-selected-runbook">Get Runtime used in selected Runbook</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-AzureAutomationRunbookRuntime</span>
</code></pre>
<h3 id="heading-set-runbook-runtime">Set Runbook Runtime</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Set-AzureAutomationRunbookRuntime</span>
</code></pre>
<h3 id="heading-set-runbook-description">Set Runbook description</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Set-AzureAutomationRuntimeDescription</span>
</code></pre>
<h3 id="heading-start-runbook-test-run-using-selected-runtime">Start Runbook test run using selected Runtime</h3>
<p>Useful if you want to automate testing of the new Runtimes a.k.a. that your Runbook will successfully end with this new Runtime (modules) version, before the final assignment.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Start-AzureAutomationRunbookTestJob</span> <span class="hljs-literal">-runtimeName</span> <span class="hljs-string">"CustomPSH_7.2_v2"</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-string">"ExchangeSetAuditSettings"</span>
</code></pre>
<h3 id="heading-stop-runbook-test-run">Stop Runbook test run</h3>
<pre><code class="lang-powershell"><span class="hljs-built_in">Stop-AzureAutomationRunbookTestJob</span>
</code></pre>
<h3 id="heading-get-runbook-test-run-output">Get Runbook test run output</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># get the output as array of string</span>
<span class="hljs-built_in">Get-AzureAutomationRunbookTestJobOutput</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-string">"ExchangeSetAuditSettings"</span> <span class="hljs-literal">-justText</span> <span class="hljs-string">'Output'</span>, <span class="hljs-string">'Warning'</span>, <span class="hljs-string">'Error'</span>, <span class="hljs-string">'Exception'</span>

<span class="hljs-comment"># get the output as array of objects</span>
<span class="hljs-built_in">Get-AzureAutomationRunbookTestJobOutput</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-string">"ExchangeSetAuditSettings"</span>
</code></pre>
<h3 id="heading-get-runbook-test-run-status">Get Runbook test run status</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># get test run status </span>
<span class="hljs-built_in">Get-AzureAutomationRunbookTestJobStatus</span> <span class="hljs-literal">-runbookName</span> <span class="hljs-string">"ExchangeSetAuditSettings"</span>
</code></pre>
]]></content:encoded></item><item><title><![CDATA[How to authenticate to Microsoft Graph from Azure DevOps Pipeline using Workload identity federation]]></title><description><![CDATA[With the introduction of the Workload Identity Federation feature (currently in preview but functioning well), we can leverage the identity associated with our Pipeline for authentication with Microsoft Graph and other Azure services, eliminating the...]]></description><link>https://doitpshway.com/how-to-authenticate-to-microsoft-graph-from-azure-devops-pipeline-using-workload-identity-federation</link><guid isPermaLink="true">https://doitpshway.com/how-to-authenticate-to-microsoft-graph-from-azure-devops-pipeline-using-workload-identity-federation</guid><category><![CDATA[Powershell]]></category><category><![CDATA[workload-identity]]></category><category><![CDATA[Azure]]></category><category><![CDATA[Devops]]></category><category><![CDATA[msgraph]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 27 Mar 2024 12:22:08 GMT</pubDate><content:encoded><![CDATA[<p>With the introduction of the <strong>Workload Identity Federation</strong> feature (currently in preview but functioning well), we can leverage the identity associated with our Pipeline for authentication with Microsoft Graph and other Azure services, eliminating the need for additional Service Principals.</p>
<p>This means we no longer have to deal with the inconvenience of renewing saved secrets 💗.</p>
<p>To get more details about the <strong>Workload Identity Federation</strong> check the official documentation.</p>
<hr />
<h1 id="heading-create-the-workload-identity-federation-service-connection">Create the Workload Identity Federation Service Connection</h1>
<p>In your Azure DevOps project: <code>Project settings</code> &gt;&gt; <code>Service connections</code> &gt;&gt; <code>Create service connection</code> &gt;&gt; <code>Azure Resource Manager</code> &gt;&gt; <code>Workload Identity federation (automatic)</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711537494345/2128ddc4-73f4-4942-8a07-2d7fcf102f42.png" alt /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711537664167/b67b0200-6ade-49ff-88c1-c20bb3bb95e0.png" alt /></p>
<p>Now you need to specify to what resource you will grant this newly created identity permissions to.</p>
<p>For this particular use case, I tend to use <code>Subscription</code> (<code>Resource Group</code>), which means that identity will be granted a <code>Contributor</code> role over the selected <code>Resource Group</code>.</p>
<p>It is a good idea to have a separate subscription with some dummy <code>Resource Group</code> just for this!</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💥</div>
<div data-node-type="callout-text">The <code>Contributor</code> role can be substituted with a less privileged role, such as <code>Reader</code>. However, it’s important to remember that workload identity <strong>must be assigned some role</strong>, otherwise all authentication attempts will fail!</div>
</div>

<p><code>Service connection name</code> will be used in the pipeline as an identity identifier later.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711539087225/514fbf4d-d78b-4920-9a61-83bb594296f6.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-grant-required-graph-permissions-to-our-identity">Grant required Graph permissions to our identity</h1>
<p>Now when the identity is created</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711539631658/003dc238-8490-4791-a32a-c26e7888de5c.png" alt /></p>
<p>We can assign it the required API permissions</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711539720989/f4e80fd4-96d0-4a52-b3aa-e6c45f926071.png" alt /></p>
<p>As you can see, a new App registration was created in our Azure tenant named as <code>&lt;DevOps Organization&gt;-&lt;DevOps Project&gt;-&lt;GUID&gt;</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711539974932/b93b02d9-d0e6-44f5-b0f8-a1387464e42f.png" alt class="image--center mx-auto" /></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">To grant permissions programmatically, you can use <code>Grant-AzureServicePrincipalPermission</code> (part of the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureApplicationStuff"><code>AzureApplicationStuff</code></a> module)</div>
</div>

<hr />
<h1 id="heading-authenticate-to-microsoft-graph-within-the-pipeline-using-the-created-workload-identity">Authenticate to Microsoft Graph within the Pipeline using the created Workload Identity</h1>
<p>Now the last part where we use created workload identity to authenticate against Graph API.</p>
<h2 id="heading-getting-the-access-token">Getting the access token</h2>
<p>To get the access token, add following <code>AzurePowerShell@5</code> step to your pipeline.</p>
<pre><code class="lang-yaml"><span class="hljs-bullet">-</span> <span class="hljs-attr">task:</span> <span class="hljs-string">AzurePowerShell@5</span>
        <span class="hljs-attr">displayName:</span> <span class="hljs-string">"Get Graph Token for Workload Federated Credential"</span>
        <span class="hljs-attr">inputs:</span>
          <span class="hljs-attr">azureSubscription:</span> <span class="hljs-string">"workload_identity_for_graph_api"</span>
          <span class="hljs-attr">azurePowerShellVersion:</span> <span class="hljs-string">"LatestVersion"</span>
          <span class="hljs-attr">ScriptType:</span> <span class="hljs-string">"inlineScript"</span>
          <span class="hljs-attr">Inline:</span> <span class="hljs-string">|
            $accessToken = (Get-AzAccessToken -ResourceTypeName MSGraph -ErrorAction Stop).Token
            Write-Host "##vso[task.setvariable variable=accessToken;issecret=true]$accessToken"</span>
</code></pre>
<p>Don't forget to change the value of <code>azureSubscription</code> to match your <code>Service connection name</code> created in the previous step!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1711541157205/f95c66b1-cb66-4914-bae0-bb976f563e48.png" alt class="image--center mx-auto" /></p>
<p>This task will get the Graph access token and save it to the pipeline variable <code>accessToken</code> for later use.</p>
<h2 id="heading-using-the-access-token-to-authenticate">Using the access token to authenticate</h2>
<p>Now that we have the access token, add the following step to your Pipeline</p>
<pre><code class="lang-yaml"> <span class="hljs-bullet">-</span> <span class="hljs-attr">task:</span> <span class="hljs-string">PowerShell@2</span>
        <span class="hljs-attr">displayName:</span> <span class="hljs-string">"Authenticate"</span>
        <span class="hljs-attr">inputs:</span>
          <span class="hljs-attr">targetType:</span> <span class="hljs-string">"inline"</span>
          <span class="hljs-attr">script:</span> <span class="hljs-string">|
            Write-Host "Authenticating to Graph API"
            $secureToken = ConvertTo-SecureString -String $(accessToken) -AsPlainText -Force
            Connect-MgGraph -AccessToken $secureToken -NoWelcome</span>
</code></pre>
<p>This step converts the string from the <code>accessToken</code> pipeline variable to the secure string required by the <code>Connect-MgGraph</code> command and use it to authenticate.</p>
<p>Happy scripting 😉</p>
]]></content:encoded></item><item><title><![CDATA[Invoke-Command alternative for Intune-managed Windows devices]]></title><description><![CDATA[Continue reading if you want to have the ability to run PowerShell code (similar to Invoke-Command) against your Windows Intune-managed hybrid and cloud-only clients in a nearly real-time fashion and at the same time get the code results back 😎

💡
...]]></description><link>https://doitpshway.com/invoke-command-alternative-for-intune-managed-windows-devices</link><guid isPermaLink="true">https://doitpshway.com/invoke-command-alternative-for-intune-managed-windows-devices</guid><category><![CDATA[Powershell]]></category><category><![CDATA[intune]]></category><category><![CDATA[remediation]]></category><category><![CDATA[remote]]></category><category><![CDATA[management]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Wed, 20 Mar 2024 14:38:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1710937061252/6ba52858-69d4-4c0e-a3ab-6929c62c970f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Continue reading if you want to have the ability to run PowerShell code (similar to <code>Invoke-Command</code>) against your Windows Intune-managed hybrid and cloud-only clients in a nearly real-time fashion and at the same time get the code results back 😎</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">When I say "nearly real-time", I’m referring to a delay of just a few minutes, which, in the context of Intune, is practically instantaneous 😁</div>
</div>

<p>I came across a thread on Reddit discussing the use of on-demand remediations in Intune to retrieve basic client data. However, I couldn’t find any existing PowerShell function for this purpose, so I decided to create one myself.</p>
<p>Meet my shiny, brand-new <code>Invoke-IntuneCommand</code> function (part of the <a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a> module).</p>
<h3 id="heading-use-cases">Use cases</h3>
<ul>
<li><p>You want to run some one-time PowerShell code against your Windows devices ASAP</p>
</li>
<li><p>You want to get some Windows clients' data back (is a service running, is an application installed,...)</p>
</li>
</ul>
<hr />
<h1 id="heading-how-does-invoke-intunecommand-work">How does Invoke-IntuneCommand work?</h1>
<ul>
<li><p>New Intune <strong>remediation is created</strong> in the background, utilizing the code to be invoked within the <em>detection</em> component</p>
</li>
<li><p><strong>Remediation is invoked</strong> via an <strong>on-demand request</strong> for each selected device</p>
</li>
<li><p>The function either <strong>waits for the remediation to complete</strong> or for the specified timeout to be reached (10 minutes by default, but can be overridden) in a which-comes-first manner</p>
</li>
<li><p>Remediation <strong>code results are retrieved</strong> (and converted to an object if the result is a compressed JSON string)</p>
</li>
<li><p><strong>Remediation is deleted</strong> (this step can vary based on the used parameters and remediation invocation results)</p>
</li>
</ul>
<p>The whole process can be nicely seen when <code>Verbose</code> parameter is used 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710938105078/d4ad985b-8ac3-4c9a-9f38-fc285bf74508.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-what-does-the-invoke-intunecommand-output-look-like">What does the Invoke-IntuneCommand output look like</h1>
<p>Function <code>Invoke-IntuneCommand</code> returns a custom object for each device where the command was run.</p>
<p>The returned object contains several properties</p>
<ul>
<li><p><code>DeviceId</code> - managed device ID (Intune ID)</p>
</li>
<li><p><code>DeviceName</code> - name of the device</p>
</li>
<li><p><code>LastSyncDateTime</code> - when it contacted Intune the last time</p>
</li>
<li><p><code>ProcessedOutput</code> - returned output converted to an object if only a compressed JSON string was returned</p>
</li>
<li><p><code>Output</code> - string output of the invoked command (just the last line!)</p>
</li>
<li><p><code>Error</code> - thrown error if any</p>
</li>
<li><p><code>Status</code> - overall invoked remediation status</p>
<ul>
<li><p><strong>pending</strong> if not being run</p>
</li>
<li><p><strong>fail</strong> if some error was thrown</p>
</li>
<li><p><strong>success</strong> if everything was ok</p>
</li>
</ul>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710937415749/1697d36d-0111-4fbd-8e29-c04e47f2b63a.png" alt class="image--center mx-auto" /></p>
<p>If your code throws an error, you will see the error message in <code>Error</code> property and Status will be <code>fail</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710934959325/fc852a9c-4837-4783-a67c-c1a6ad867658.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-requirements">Requirements</h1>
<ul>
<li><p>Module <a target="_blank" href="https://www.powershellgallery.com/packages/IntuneStuff">IntuneStuff</a></p>
</li>
<li><p>Graph permissions</p>
<ul>
<li><p>DeviceManagementConfiguration.Read.All</p>
</li>
<li><p>DeviceManagementManagedDevices.Read.All</p>
</li>
<li><p>DeviceManagementManagedDevices.PrivilegedOperations.All</p>
</li>
</ul>
</li>
<li><p><a target="_blank" href="https://learn.microsoft.com/en-us/mem/intune/fundamentals/remediations#licensing">License</a> to use Intune Remediation</p>
<ul>
<li><p>Windows 10/11 Enterprise E3 or E5 (included in Microsoft 365 F3, E3, or E5)</p>
</li>
<li><p>Windows 10/11 Education A3 or A5 (included in Microsoft 365 A3 or A5)</p>
</li>
<li><p>Windows 10/11 Virtual Desktop Access (VDA) per user</p>
</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-examples">Examples</h1>
<h2 id="heading-authenticate-to-graph-api-with-correct-scopes">Authenticate to Graph API with correct scopes</h2>
<pre><code class="lang-powershell"><span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-Scopes</span> DeviceManagementConfiguration.Read.All, DeviceManagementManagedDevices.Read.All, DeviceManagementManagedDevices.PrivilegedOperations.All
</code></pre>
<h2 id="heading-run-some-command-against-all-clients">Run some command against all clients</h2>
<pre><code class="lang-powershell"><span class="hljs-variable">$deviceNameList</span> = <span class="hljs-built_in">Get-MgBetaDeviceManagementManagedDevice</span> <span class="hljs-literal">-Filter</span> <span class="hljs-string">"OperatingSystem eq 'Windows' and OwnerType eq 'company'"</span> <span class="hljs-literal">-all</span> <span class="hljs-literal">-Property</span> DeviceName | <span class="hljs-built_in">select</span> <span class="hljs-literal">-ExpandProperty</span> DeviceName

<span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-variable">$deviceNameList</span> <span class="hljs-literal">-command</span> <span class="hljs-string">"New-Item C:\temp2 -ItemType Directory"</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<h2 id="heading-create-a-folder-on-the-selected-clients">Create a folder on the selected clients</h2>
<pre><code class="lang-powershell"><span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-string">"PC-01"</span> <span class="hljs-literal">-command</span> <span class="hljs-string">"New-Item C:\temp2 -ItemType Directory"</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<p>In a few minutes (if the device is online) you should see an output similar to this one 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710933994195/b2c8bf24-cb1e-414f-8e88-d1fc3a1ee9da.png" alt /></p>
<p>Hence folder <code>C:\temp2</code> was created.</p>
<h2 id="heading-check-if-the-folder-exists-and-return-a-simple-string">Check if the folder exists and return a simple string</h2>
<pre><code class="lang-powershell"><span class="hljs-variable">$command</span> = <span class="hljs-string">@'
if (Test-Path "C:\temp2") {
    Write-Output "Folder exists"
} else {
    Write-Output "Folder doesn't exist"
}
'@</span>

<span class="hljs-variable">$result</span> = <span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-string">"PC-01"</span> <span class="hljs-literal">-command</span> <span class="hljs-variable">$command</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<p>In a few minutes (if the device is online) you should see an output similar to this one 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710935443241/d9aecc86-19e9-4435-8c47-7becc5a60d72.png" alt /></p>
<h2 id="heading-return-an-array-of-objects-of-all-running-client-powershell-processes">Return an array of objects (of all running client PowerShell processes)</h2>
<pre><code class="lang-powershell"><span class="hljs-variable">$command</span> = <span class="hljs-string">@'
$result = Get-Process powershell | select processname, id
$result | ConvertTo-Json -Compress
'@</span>

<span class="hljs-variable">$result</span> = <span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-string">"PC-01"</span> <span class="hljs-literal">-command</span> <span class="hljs-variable">$command</span> <span class="hljs-literal">-Verbose</span>

<span class="hljs-comment"># output the results</span>
<span class="hljs-variable">$result</span>

<span class="hljs-comment"># output just returned JSON string converted back to the object</span>
<span class="hljs-variable">$result</span>.processedOutput
</code></pre>
<p>In a few minutes (if the device is online) you should see an output similar to this one 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710936858290/0998dd94-488b-4735-8109-4ea35d0fe3fd.png" alt class="image--center mx-auto" /></p>
<p>Because we have converted the output to compressed JSON, it was automatically converted back to an object and saved in the <code>ProcessedOutput</code> property.</p>
<h2 id="heading-maximize-the-amount-of-returned-data-by-compression">Maximize the amount of returned data by compression</h2>
<p>If the output you need is over the 2048-character hard limit, you can reduce its length by compressing it using <code>ConvertTo-CompressedString</code> function (part of the <a target="_blank" href="https://www.powershellgallery.com/packages/CommonStuff/">CommonStuff</a> module). This can make it 10x times smaller (but it depends on the output content solely)!</p>
<p><code>Invoke-Intunecommand</code> function automatically tries to decompress the received output, so you don't have to worry about this part.</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$command</span> = <span class="hljs-string">@'
    $result = Get-Process powershell | select processname, id
    $result | ConvertTo-Json -Compress | ConvertTo-CompressedString
'@</span>

<span class="hljs-comment"># invoke selected command</span>
<span class="hljs-variable">$result</span> = <span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-string">"PC-01"</span> <span class="hljs-literal">-command</span> <span class="hljs-variable">$command</span> <span class="hljs-literal">-Verbose</span>

<span class="hljs-comment"># output the results</span>
<span class="hljs-variable">$result</span>

<span class="hljs-comment"># output just returned compressed JSON string converted back to the object</span>
<span class="hljs-variable">$result</span>.processedOutput
</code></pre>
<h2 id="heading-adding-command-definition-to-the-code-block">Adding command definition to the code block</h2>
<p>Any command you run on the remote device needs to exist there. This specifically applies to the custom functions. You can add such a function directly to the invoked command like this</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$command</span> = <span class="hljs-string">@'
    # Get-SomeData definition is inside the code block defined manually
    function Get-SomeData {
        do some stuff...
    }

    Get-SomeData | ConvertTo-Json -Compress
'@</span>
</code></pre>
<p>Or use parameter <code>prependCommandDefinition</code> 😎 like this</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$command</span> = <span class="hljs-string">@'
    # ConvertTo-CompressedString definition is added automatically thanks to 'prependCommandDefinition' parameter 
    $result = Get-Process powershell | select processname, id
    $result | ConvertTo-Json -Compress | ConvertTo-CompressedString
'@</span>

<span class="hljs-comment"># text definition of the ConvertTo-CompressedString function will be added to the command, so it doesn't matter whether it is available on the remote system</span>
<span class="hljs-built_in">Invoke-Intunecommand</span> <span class="hljs-literal">-deviceName</span> <span class="hljs-string">"PC-01"</span> <span class="hljs-literal">-prependCommandDefinition</span> <span class="hljs-built_in">ConvertFrom-CompressedString</span> <span class="hljs-literal">-command</span> <span class="hljs-variable">$command</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<hr />
<h1 id="heading-limitations">Limitations</h1>
<ul>
<li><p>Returned output is <strong>limited to 2048 chars</strong> (Intune inner limitation)</p>
</li>
<li><p>Only the <strong>last output line</strong> is returned</p>
<ul>
<li><p>hence if you run the following code <code>write-output 'aaa'; write-output 'bbb'</code></p>
</li>
<li><p>the returned output will be just <code>'bbb'</code>!</p>
</li>
</ul>
</li>
<li><p><code>Write-Host</code> cannot be used, instead use <code>Write-Output</code></p>
</li>
<li><p>If the "helper" remediation is removed, any clients that have not already invoked it will be unable to do so</p>
</li>
</ul>
<hr />
<h1 id="heading-tips">Tips</h1>
<ul>
<li><p>Definitely <strong>check the function help</strong> (<code>Get-Help Invoke-IntuneCommand -Full</code>) to get more details &amp; examples</p>
</li>
<li><p>To get as much data back as possible, convert the output using <code>ConvertTo-CompressedString</code> function (part of the <a target="_blank" href="https://www.powershellgallery.com/packages/CommonStuff/">CommonStuff</a> module) after converting it to the compressed JSON string. This way you can get 10x more data back. And the <code>Invoke-IntuneCommand</code> will try to decompress it automatically 👍</p>
</li>
<li><p>Invoke the function with the <code>Verbose</code> parameter to obtain more detailed information, such as the remaining time until the deadline is reached</p>
</li>
<li><p>If you wish to <strong>transform the result back into an object</strong>, ensure that your command returns a single result, specifically the <strong>compressed JSON</strong></p>
<ul>
<li><code>Get-Process powershell | select processname, id | ConvertTo-Json -Compress</code></li>
</ul>
</li>
<li><p>If your <strong>command throws an error, the whole invocation takes more time</strong>, because a dummy remediation command (<code>exit 0</code>) will be run too (because we are using remediation and if the detection part fails, the remediation part takes place)</p>
</li>
<li><p>Helper remediations are named like <code>_invCmd_&lt;yyyy.MM.dd_HH:mm&gt;</code></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to get all Graph PowerShell SDK modules required to run selected code using PowerShell]]></title><description><![CDATA[In my previous article, I've shown you, how to get the permissions required to run selected PowerShell code. Today I will focus on getting Microsoft Graph PowerShell SDK modules.
The official Find-MgGraphCommand function, which retrieves the parent m...]]></description><link>https://doitpshway.com/how-to-get-all-graph-powershell-sdk-modules-required-to-run-selected-code-using-powershell</link><guid isPermaLink="true">https://doitpshway.com/how-to-get-all-graph-powershell-sdk-modules-required-to-run-selected-code-using-powershell</guid><category><![CDATA[Powershell]]></category><category><![CDATA[dependencies]]></category><category><![CDATA[Graph]]></category><category><![CDATA[msgraph]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Mon, 11 Mar 2024 17:01:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1710159203262/2492f4f6-3cbe-41c9-83a2-55a19cffee29.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In my <a target="_blank" href="https://doitpsway.com/how-to-get-all-graph-api-permissions-required-to-run-selected-code-using-powershell">previous article</a>, I've shown you, how to get the permissions required to run selected PowerShell code. Today I will focus on getting <a target="_blank" href="https://learn.microsoft.com/en-us/powershell/microsoftgraph/overview?view=graph-powershell-1.0">Microsoft Graph PowerShell SDK modules</a>.</p>
<p>The official <code>Find-MgGraphCommand</code> function, which retrieves the parent module for Graph <code>Mg*</code> commands, is undoubtedly beneficial. However, the task of extracting all these commands (including those for direct Graph API calls) from analyzed code, remains a challenging and tedious process.</p>
<p>Let's meet my PowerShell function <code>Get-CodeGraphModuleDependency</code> (part of the module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a>) that solves all these issues.</p>
<hr />
<h1 id="heading-introduction">Introduction</h1>
<p>Function <code>Get-CodeGraphModuleDependency</code> gets <strong>Graph PowerShell SDK modules</strong> that are needed to run selected code.</p>
<p>Under the hood, it uses my other, more universal function <code>Get-CodeDependency</code> (part of <a target="_blank" href="https://www.powershellgallery.com/packages/DependencySearch">DependencySearch</a> module) that returns all code dependencies by analyzing its AST and doing some other magic, to search for all official <code>Mg*</code> commands (like <code>Get-MgUser</code>, ...).</p>
<p>When <code>Mg*</code> commands are extracted, an official <code>Find-MgGraphCommand</code> command is used to get their hosting SDK modules.</p>
<p><code>Get-CodeGraphModuleDependency</code> is part of the module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a>.</p>
<hr />
<h1 id="heading-main-features">Main features</h1>
<ul>
<li><p><strong>Extracts</strong> all official <strong>Mg* Graph commands</strong> from the given code and returns their parent <strong>PowerShell SDK modules</strong></p>
<ul>
<li>returns <code>Microsoft.Graph.Users</code> module for <code>Get-MgUser</code>, <code>Microsoft.Graph.Identity.DirectoryManagement</code> for <code>Update-MgDevice</code>, ...</li>
</ul>
</li>
<li><p><strong>Extracts</strong> and returns <strong>explicitly imported</strong> PowerShell SDK modules</p>
<ul>
<li>returns <code>Microsoft.Graph.Users</code> in case of <code>Import-Module Microsoft.Graph.Users</code></li>
</ul>
</li>
<li><p>Supports <strong>recursive search across all code dependencies</strong></p>
<ul>
<li>so you can get the complete modules list not just for the code itself, but for all its dependencies too</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-the-output-of-the-get-codegraphmoduledependency">The output of the Get-CodeGraphModuleDependency</h1>
<p>If we send the function results to <code>Out-GridView</code> to get a graphical representation, we can get results similar to this 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710159181313/2508bc32-8803-4f67-a2b8-f0b66f5d512b.png" alt class="image--center mx-auto" /></p>
<p>As you can see there are several properties returned for each found module:</p>
<ul>
<li><p><strong>Name</strong> - name of the Graph SDK module that is needed</p>
</li>
<li><p><strong>Version</strong> - version (if specified) of the Graph SDK module that is needed</p>
<ul>
<li>for example when <code>Import-Module</code> with <code>RequiredVersion</code> parameter is used</li>
</ul>
</li>
<li><p><strong>RequiredBy</strong> - the original code line where the command that requires this module was found</p>
</li>
<li><p><strong>DependencyPath</strong> - the whole path to the found command</p>
<ul>
<li>useful when using <code>goDeep</code> parameter to understand where the command was found</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-use-cases">Use cases</h1>
<p>The following examples will be made against this test code</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710160121251/be76bc28-ca1e-4962-a90f-e970c8ca4cd3.png" alt class="image--center mx-auto" /></p>
<p>Can be downloaded from <a target="_blank" href="https://gist.github.com/ztrhgf/82bfe9128996c9f8abfcc289bad3e491">GitHub Gist</a>.</p>
<h2 id="heading-return-graph-powershell-sdk-modules-required-by-selected-code">Return Graph PowerShell SDK modules required by selected code</h2>
<p>The following code will return only modules directly required by code in the selected script.</p>
<p>If there are some indirect dependencies (like calling another function that invokes some Graph commands itself), they won't be returned!</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-CodeGraphModuleDependency</span> <span class="hljs-literal">-scriptPath</span> C:\scripts\someGraphRelatedCode2.ps1 | <span class="hljs-built_in">ogv</span>
</code></pre>
<p>The result will look like this 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710160439685/a0707f10-da66-412a-9441-d598a4709082.png" alt class="image--center mx-auto" /></p>
<p>When you compare the result with the test code you can notice that:</p>
<ul>
<li><p>unrelated commands like <code>Get-Process</code> are ignored</p>
</li>
<li><p>specified module version in line with <code>Import-Module</code> gets its way to <code>Version</code> property</p>
</li>
<li><p>each module is returned only once hence Microsoft.GraphApplications is returned for <code>Get-MgApplication</code>, but not for <code>Update-MgApplication</code></p>
<ul>
<li>to change this, use the switch <code>allOccurrences</code></li>
</ul>
</li>
</ul>
<h2 id="heading-return-all-graph-powershell-sdk-modules-required-by-selected-code-and-its-dependencies">Return ALL Graph PowerShell SDK modules required by selected code and its dependencies</h2>
<p>The following code will return all modules required by the code in the selected script, no matter if the module was needed in the code itself, or in the called functions, etc.</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-CodeGraphModuleDependency</span> <span class="hljs-literal">-scriptPath</span> C:\scripts\someGraphRelatedCode2.ps1 <span class="hljs-literal">-goDeep</span> | <span class="hljs-built_in">ogv</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710160841231/51f7f30c-f7d0-4f81-ab46-78216f41666a.png" alt class="image--center mx-auto" /></p>
<p>As you can see there are some new results returned. Those two new results belong to 3rd party function <code>Remove-O365OrphanedMailbox</code> that is being called in the test script. And thanks to <code>goDeep</code> parameter it was now searched for the dependencies too.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Use the VERBOSE parameter when calling <code>Get-CodeGraphPermissionRequirement</code> to get more details about what is going on under the hood</div>
</div>

<hr />
<p>Now that you know how to use this function don't be afraid to test it against your code. Hopefully, this will help you on your Graph API journey 👍</p>
]]></content:encoded></item><item><title><![CDATA[How to get all Graph API permissions required to run selected code using PowerShell]]></title><description><![CDATA[Now that Microsoft Graph API is the main management tool for most of the Microsoft Cloud services, more and more admins will need to be able to work effectively with it.
Graph API can be quite hard to understand, mainly the scope/permission part of i...]]></description><link>https://doitpshway.com/how-to-get-all-graph-api-permissions-required-to-run-selected-code-using-powershell</link><guid isPermaLink="true">https://doitpshway.com/how-to-get-all-graph-api-permissions-required-to-run-selected-code-using-powershell</guid><category><![CDATA[Powershell]]></category><category><![CDATA[dependencies]]></category><category><![CDATA[Graph]]></category><category><![CDATA[msgraph]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Mon, 11 Mar 2024 16:58:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1710146588243/fa457828-9473-4bcb-92b3-130415c9a414.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Now that Microsoft Graph API is the main management tool for most of the Microsoft Cloud services, more and more admins will need to be able to work effectively with it.</p>
<p>Graph API can be quite hard to understand, mainly the scope/permission part of it. One thing is to write the correct code and the second is knowing, what permission will you need to run it successfully 😄</p>
<p>Not to mention running some not-very-well-documented 3rd party code.</p>
<p>In this post, I will show you my solution to this problem. And that is my PowerShell function <code>Get-CodeGraphPermissionRequirement</code> (part of the module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a>).</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There is a related article that focuses on getting required <a target="_blank" href="https://doitpsway.com/how-to-get-all-graph-powershell-sdk-modules-required-to-run-selected-code-using-powershell">Graph SDK modules</a></div>
</div>

<hr />
<h1 id="heading-introduction">Introduction</h1>
<p>Function <code>Get-CodeGraphPermissionRequirement</code> gets <strong>Graph API permissions</strong> (scopes) that are needed to run selected code.</p>
<p>Under the hood, it uses my other, more universal function <code>Get-CodeDependency</code> (part of <a target="_blank" href="https://www.powershellgallery.com/packages/DependencySearch">DependencySearch</a> module) that returns all code dependencies by analyzing its AST and doing some other magic, to search for all commands interacting with the Graph API.</p>
<p>Official <strong>Graph SDK commands</strong> and <strong>direct Graph API calls</strong> are both processed 😎</p>
<p><code>Get-CodeGraphPermissionRequirement</code> is part of the module <a target="_blank" href="https://www.powershellgallery.com/packages/MSGraphStuff">MSGraphStuff</a>.</p>
<hr />
<h1 id="heading-main-features">Main features</h1>
<ul>
<li><p>Supports <strong>getting permissions for official Mg* Graph SDK commands</strong></p>
<ul>
<li><code>Get-MgUser</code>, <code>Update-MgDevice</code>, ...</li>
</ul>
</li>
<li><p>Supports <strong>getting permissions for direct API calls</strong> invoked via <code>Invoke-MsGraphRequest</code>, <code>Invoke-RestMethod</code>, <code>Invoke-WebRequest</code> and their aliases</p>
<ul>
<li><p>a.k.a. calling GET, POST, ... requests against <code>https://graph.microsoft.com/v1.0/users</code> and such</p>
</li>
<li><p>supports also usage of variables in place of IDs in the URI</p>
<ul>
<li>a.k.a. <code>v1.0/groups/$groupId/settings</code> will be correctly recognized and processed</li>
</ul>
</li>
</ul>
</li>
<li><p>Supports <strong>recursive search across all code dependencies</strong></p>
<ul>
<li>so you can get the complete permissions list not just for the code itself, but for all its dependencies too</li>
</ul>
</li>
<li><p>Recognizes <code>v1.0</code> vs <code>beta</code> API calls</p>
</li>
</ul>
<h1 id="heading-drawbacks">Drawbacks</h1>
<ul>
<li><p>Official command <code>Find-MgGraphCommand</code> is used to translate command/URI calls to required permissions</p>
<ul>
<li><p><strong>doesn't contain permissions for all</strong> commands/URIs (but this will be hopefully solved in the future)</p>
</li>
<li><p>returns <strong>all permissions</strong> that can be used, <strong>not just the least one</strong> (but I am trying to solve this on my own)</p>
</li>
</ul>
</li>
<li><p><strong>URIs passed via parameter splatting or through variables aren't detected right now</strong></p>
<ul>
<li>but you will be notified about such issue, so you can solve this manually</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-the-output-of-the-get-codegraphpermissionrequirement">The output of the Get-CodeGraphPermissionRequirement</h1>
<p>If we send the function results to <code>Out-GridView</code> to get a graphical representation, we can get results similar to this</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710001800631/984f5798-a822-4dc2-b69e-e483ecd4d648.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710001841627/ccc40e14-ca0e-44e3-873a-d859a6d39cc3.png" alt class="image--center mx-auto" /></p>
<p>As you can see there are several properties returned:</p>
<ul>
<li><p><strong>Command</strong> - detected command that interacts with Graph API</p>
<ul>
<li>official Mg* commands or direct API web requests</li>
</ul>
</li>
<li><p><strong>Name</strong> - name of the Graph permission/scope that the command needs</p>
</li>
<li><p><strong>Description</strong> - Graph permission description</p>
</li>
<li><p><strong>FullDescription</strong> - Graph permission full description</p>
</li>
<li><p><strong>Type</strong> - Graph permission type (application, delegated)</p>
</li>
<li><p><strong>InvokedAs</strong> - the original code line where the command was found</p>
<ul>
<li>helpful if some command was found multiple times to identify the calls, also in cases where URI cannot be recognized, to find such line and do the manual code analysis</li>
</ul>
</li>
<li><p><strong>DependencyPath</strong> - the whole path to the found command</p>
<ul>
<li>useful when using <code>goDeep</code> parameter to understand where this command was found</li>
</ul>
</li>
<li><p><strong>ApiVersion</strong> - detected Api version (<code>v1.0</code> or <code>beta</code>)</p>
</li>
<li><p><strong>Method</strong> - request method (<code>GET</code>, <code>POST</code>, <code>DELETE</code>, ...)</p>
</li>
<li><p><strong>Error</strong> - error message if there was some problem</p>
</li>
</ul>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">A detected command can be returned multiple times in case more than one permission is needed (one line per permission)!</div>
</div>

<hr />
<h1 id="heading-use-cases">Use cases</h1>
<p>The following examples will be made against this test code</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710001616381/33e8d17b-8acc-4b35-80e4-ed8410ef0301.png" alt class="image--center mx-auto" /></p>
<p>Can be downloaded from <a target="_blank" href="https://gist.github.com/ztrhgf/0e6d313f58130c19c976156b0e77723d">GitHub Gist</a>.</p>
<h2 id="heading-return-graph-permissions-required-by-selected-code">Return Graph permissions required by selected code</h2>
<p>The following code will return only <code>application</code> permissions for the selected script.</p>
<p>If there are some indirect dependencies (like calling another function that invokes some Graph API itself), they won't be returned!</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Get-CodeGraphPermissionRequirement</span> <span class="hljs-literal">-scriptPath</span> C:\scripts\someGraphRelatedCode.ps1 <span class="hljs-literal">-permType</span> <span class="hljs-string">"application"</span> | <span class="hljs-built_in">Out-GridView</span>
</code></pre>
<p>The result will look like this 👇</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710001800631/984f5798-a822-4dc2-b69e-e483ecd4d648.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710001841627/ccc40e14-ca0e-44e3-873a-d859a6d39cc3.png" alt class="image--center mx-auto" /></p>
<p>Except for two command calls the <code>Get-CodeGraphPermissionRequirement</code> function resolved all needed permissions for us.</p>
<ul>
<li><p>The first problematic one is <code>Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies" -Body $body</code> where the official <code>Find-MgGraphCommand</code> function doesn't return anything for this particular URI and PATCH method.</p>
</li>
<li><p>The second one is <code>Invoke-MgGraphRequest -OutputType PSObject -Uri $msGraphPermissionsRequestUri</code> where instead of plaintext URI is used some variable which is currently not supported by my function.</p>
</li>
</ul>
<p>The last thing that may grab your attention is the missing output for the custom function <code>Remove-O365OrphanedMailbox</code>. Trust me, this function uses Graph API, but because we haven't specified <code>goDeep</code> parameter when calling <code>Get-CodeGraphPermissionRequirement</code>, just the specified code was checked, but none of the used functions/modules. We will look into this in the next example though.</p>
<h2 id="heading-return-all-graph-permissions-required-to-run-selected-code-direct-and-indirect">Return ALL Graph permissions required to run selected code (direct and indirect)</h2>
<p>The following code will return <code>delegated</code> and <code>application</code> permissions for selected script and all its dependencies (used functions/modules)</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Because locally available modules are cached to <code>$availableModules</code> variable, you can use such a variable in the next <code>Get-CodeGraphPermissionRequirement</code> calls to speed it up</div>
</div>

<pre><code class="lang-powershell"><span class="hljs-comment"># cache available modules to speed up repeated 'Get-CodeGraphPermissionRequirement' function invocations</span>
<span class="hljs-variable">$availableModules</span> = <span class="hljs-selector-tag">@</span>(<span class="hljs-built_in">Get-Module</span> <span class="hljs-literal">-ListAvailable</span>)

<span class="hljs-built_in">Get-CodeGraphPermissionRequirement</span> <span class="hljs-literal">-scriptPath</span> C:\scripts\someGraphRelatedCode.ps1 <span class="hljs-literal">-goDeep</span> <span class="hljs-literal">-availableModules</span> <span class="hljs-variable">$availableModules</span> <span class="hljs-literal">-permType</span> <span class="hljs-string">"application"</span>, <span class="hljs-string">"delegated"</span> | <span class="hljs-built_in">Out-GridView</span>
</code></pre>
<p>The result is very similar to the first example.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710003196621/a47b30f1-08ea-4298-ad35-7224acd722c7.png" alt class="image--center mx-auto" /></p>
<p>As you can see there are two differences though.</p>
<ul>
<li><p><code>Delegated</code> permissions are returned too</p>
</li>
<li><p>A lot of <strong>new commands</strong> were detected!</p>
<ul>
<li><p>If we scroll to the right, we can see in the <code>DependencyPath</code> column that all these commands were found in the <code>Remove-O365OrphanedMailbox</code> function.</p>
<p>  <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710146036551/8d5f72ae-5ffe-4301-aeb1-dcf074bd72d8.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>This function is defined outside the code we've analyzed, but thanks to <code>goDeep</code> parameter it was processed too!</p>
</li>
</ul>
</li>
</ul>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Use the VERBOSE parameter when calling <code>Get-CodeGraphPermissionRequirement</code> to get more details about what is going on under the hood</div>
</div>

<hr />
<h1 id="heading-returned-permission-optimization">Returned permission optimization</h1>
<p>As I said, my function uses an official <code>Find-MgGraphCommand</code> under the hood to retrieve command/URI permissions and this function is chatty. It returns more permissions that are needed to be more precise.</p>
<p>For example <code>Get-MgUser</code> command, do you really think, <code>Directory.ReadWrite.All</code> or <code>Directory.ReadWrite.All</code> are needed? I don't think so.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1710147302323/d2b8aa25-fa71-4910-8128-c431d1e89b41.png" alt class="image--center mx-auto" /></p>
<p>Therefore I remove such permission from the output automatically.</p>
<p>But if you for some reason want to retrieve all these permissions, just use <code>dontFilterPermissions</code> parameter.</p>
<hr />
<p>Now that you know how to use this function don't be afraid to test it against your code. Hopefully, this will help you on your Graph API journey 👍</p>
]]></content:encoded></item><item><title><![CDATA[Create persistent Azure Automation Runbook variables using Azure Blob Storage]]></title><description><![CDATA[Because Azure Runbooks are invoked in temporary environments, you cannot save the Runbook results for later use in the local files. Which is one of the drawbacks compared to on-premises Schedules Tasks.
But what if you need some output persistence? F...]]></description><link>https://doitpshway.com/create-persistent-azure-automation-runbook-variables-using-azure-blob-storage</link><guid isPermaLink="true">https://doitpshway.com/create-persistent-azure-automation-runbook-variables-using-azure-blob-storage</guid><category><![CDATA[Powershell]]></category><category><![CDATA[azure runbook]]></category><category><![CDATA[Storage Container]]></category><category><![CDATA[Azure]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Sat, 09 Mar 2024 14:26:47 GMT</pubDate><content:encoded><![CDATA[<p>Because Azure Runbooks are invoked in temporary environments, you cannot save the Runbook results for later use in the local files. Which is one of the drawbacks compared to on-premises Schedules Tasks.</p>
<p>But what if you need some output persistence? For example, to take note of what users have already been notified about some issue, so you don't send them the same email next time, the Runbook runs.</p>
<p>For such cases, you have several options, based on the data you need to save.</p>
<ul>
<li><p>If you need to store some simple string or integer, built-in <strong>Azure Automation Variable</strong> is the way to go</p>
</li>
<li><p>Built-in <strong>Azure Automation Variable</strong> can be used for saving some complex objects like array of hashtables etc too (check <a target="_blank" href="https://doitpsway.com/how-to-save-complex-powershell-variables-as-clixml-instead-of-newtonsoftjsonlinqjproperty-in-the-azure-automation">How to save complex PowerShell variables as CliXML instead of Newtonsoft.Json.Linq.JProperty in the Azure Automation</a> post), but there are some limitations regarding the variable size. Therefore this can be used only for "small" objects a.k.a. it is not very reliable.</p>
</li>
<li><p>But what if you need to store complex variables no matter what their size is? That's where <strong>Azure Blob Storage</strong> steps in 👍</p>
</li>
</ul>
<h1 id="heading-comparison-of-saving-variables-to-azure-blob-storage">Comparison of saving variables to Azure Blob Storage</h1>
<h2 id="heading-advantages">Advantages</h2>
<ul>
<li><p>Variables can be serialized using well-known <strong>Export-CliXml</strong> (a.k.a. saved as XML file) and then deserialized using <strong>Import-CliXml</strong> (a.k.a. converted back to the original object) PowerShell commands</p>
</li>
<li><p>You can <strong>saveany PowerShell complex object of any size</strong> without worrying about hitting some internal Automation limits</p>
</li>
<li><p>When importing the serialized variable back to the Runbook, you will get the same complex object <strong>of the same type</strong>, etc</p>
</li>
<li><p>Can be used to <strong>pass complex variables between different Runbooks</strong></p>
</li>
</ul>
<h2 id="heading-disadvantages">Disadvantages</h2>
<ul>
<li><p>Azure Storage <strong>isn't free</strong> to use, but it will cost you like 0.1 $/month (depending on the exported file size and amount of data transfers)</p>
</li>
<li><p>It's <strong>not as simple</strong> as using built-in Azure Automation Variable, because you need to create a Storage Account and set appropriate permissions</p>
</li>
<li><p>Saving and reading data from the Storage Account makes your Runbook a little bit <strong>slower</strong></p>
</li>
</ul>
<hr />
<h1 id="heading-how-to-save-the-runbook-variable-to-blob-storage-then">How to save the Runbook variable to Blob storage then?</h1>
<h2 id="heading-create-an-azure-storage-account">Create an Azure Storage Account</h2>
<p>We don't need anything special for our case, so create a <strong>Standard LRS Storage Account with Hot-tier storage</strong> (<a target="_blank" href="https://learn.microsoft.com/en-us/azure/storage/common/storage-account-create?tabs=azure-portal">official documentation</a>).</p>
<p>You can name it like <strong>persistentvariablesstore.</strong></p>
<p>Just make sure that <code>Default to Microsoft Entra authorization in the Azure portal</code> is set to <code>Disabled</code> in the Storage Account configuration pane!</p>
<h2 id="heading-create-a-container-to-store-your-variables-xml-files">Create a Container to store your variables (XML files)</h2>
<p>Create <code>Container</code> (it's like a folder). For example <strong>variables</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709991704217/129fbcdc-b279-4521-8bcb-b06b197fa6f5.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-grant-iam-role-to-the-automation-managed-identity">Grant IAM role to the Automation Managed Identity</h2>
<p>In the <code>Access Control (IAM)</code> section of the created container, grant a role <code>Storage Blob Data Contributor</code> role to your <code>Azure Automation Account Managed identity</code>. This allows the Runbook to modify data in this container.</p>
<h2 id="heading-import-the-azureresourcestuff-module-to-your-azure-automation">Import the AzureResourceStuff module to your Azure Automation</h2>
<p>To be able to use my <code>Export-VariableToStorage</code>, <code>Import-VariableFromStorage</code> PowerShell functions that will help you to save/load variables from Azure storage, you need to add the <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff">AzureResourceStuff</a> module to your Azure Automation</p>
<p>You can do this manually using <code>Add a module</code> button.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709023386795/a1428327-a1ea-4f73-9c25-2e7f7fd71c55.png?auto=compress,format&amp;format=webp" alt /></p>
<p>Or using my function <code>New-AzureAutomationModule</code> (part of my <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff"><strong>AzureResourceStuff</strong></a> module).</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">New-AzureAutomationModule</span> <span class="hljs-literal">-moduleName</span> AzureResourceStuff <span class="hljs-literal">-resourceGroupName</span> yourRESOURCEgroupNAME <span class="hljs-literal">-automationAccountName</span> yourAUTOMATIONaccountNAME
</code></pre>
<p>For more details check my post <a target="_blank" href="https://doitpsway.com/import-new-or-update-existing-powershell-module-including-its-dependencies-into-azure-automation-account-using-powershell"><strong>Import new (or update existing) PowerShell module (including its dependencies!) into Azure Automation Account using PowerShell</strong></a></p>
<h2 id="heading-use-my-powershell-functions-inside-your-runbook-to-exportimport-your-variables">Use my PowerShell functions inside your Runbook to export/import your variables</h2>
<p>Now when the <code>AzureResourceStuff</code> module is added, you are ready to save variables from your Runbook to Azure storage and vice versa.</p>
<p>Just add a similar code to your Runbook 👇</p>
<pre><code class="lang-powershell"><span class="hljs-comment"># functions Import-VariableFromStorage, Export-VariableToStorage are part of module AzureResourceStuff </span>

<span class="hljs-comment"># authenticate, so you Runbook can work with the Storage</span>
<span class="hljs-variable">$null</span> = <span class="hljs-built_in">Connect-AzAccount</span> <span class="hljs-literal">-Identity</span>

<span class="hljs-comment"># select variable name, this name will be used to store variable in the Storage (as '&lt;name&gt;.xml')</span>
<span class="hljs-variable">$persistentVariableName</span> = <span class="hljs-string">"previouslyProcessedUsers"</span>

<span class="hljs-comment"># use parameter splatting to define common parameters</span>
<span class="hljs-variable">$varFncParams</span> = <span class="hljs-selector-tag">@</span>{
    fileName = <span class="hljs-variable">$persistentVariableName</span>
    resourceGroupName = <span class="hljs-string">"PersistentRunbookVariables"</span>
    storageAccount = <span class="hljs-string">"persistentvariablesstore"</span> <span class="hljs-comment"># cASe SENSitive!</span>
    containerName = <span class="hljs-string">"variables"</span>
}

<span class="hljs-comment"># set your variable</span>
<span class="hljs-variable">$previouslyProcessed</span> = ...

<span class="hljs-comment"># to export the variable to the Storage</span>
<span class="hljs-built_in">Export-VariableToStorage</span> <span class="hljs-literal">-value</span> <span class="hljs-variable">$previouslyProcessed</span> @varFncParams

<span class="hljs-comment"># to import the variable back from the Storage</span>
<span class="hljs-variable">$previouslyProcessed</span> = <span class="hljs-built_in">Import-VariableFromStorage</span> @varFncParams
</code></pre>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💥</div>
<div data-node-type="callout-text">Don't forget to customize <code>resourceGroupName</code>, <code>storageAccount</code> and <code>containerName</code> keys in the <code>$varFncParams</code> hashtable to match your environment!</div>
</div>

<p>To get more details about functions parameters etc, check their help.</p>
<p>Happy scripting 🙂</p>
]]></content:encoded></item><item><title><![CDATA[Gradual update of all applications using WinGet and custom Azure ring groups]]></title><description><![CDATA[Today I will show you how to make the process of updating apps in your company as easy as possible 😎.
We will use:

WinGet to update the applications

Winget-AutoUpdate tool to be more specific


Intune to distribute Winget-AutoUpdate tool

Custom A...]]></description><link>https://doitpshway.com/gradual-update-of-all-applications-using-winget-and-custom-azure-ring-groups</link><guid isPermaLink="true">https://doitpshway.com/gradual-update-of-all-applications-using-winget-and-custom-azure-ring-groups</guid><category><![CDATA[Powershell]]></category><category><![CDATA[apps]]></category><category><![CDATA[update ]]></category><category><![CDATA[automation]]></category><category><![CDATA[Azure]]></category><category><![CDATA[intune]]></category><category><![CDATA[azure runbook]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Sat, 09 Mar 2024 13:08:27 GMT</pubDate><content:encoded><![CDATA[<p>Today I will show you how to make the process of updating apps in your company as easy as possible 😎.</p>
<p>We will use:</p>
<ul>
<li><p><strong>WinGet</strong> to update the applications</p>
<ul>
<li><a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate">Winget-AutoUpdate</a> tool to be more specific</li>
</ul>
</li>
<li><p><strong>Intune</strong> to distribute Winget-AutoUpdate tool</p>
</li>
<li><p>Custom <strong>Azure "ring" groups</strong> to make the update gradual</p>
<ul>
<li>by assigning the Winget-AutoUpdate package with different update settings to these "ring" groups</li>
</ul>
</li>
<li><p><strong>Azure Automation</strong> to keep our "ring" group members up to date</p>
</li>
</ul>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The application doesn't have to be installed via WinGet to be updatable using WinGet 😉</div>
</div>

<h3 id="heading-benefits">Benefits</h3>
<ul>
<li><p><strong>Free (or with like 0,5 USD/month)</strong></p>
</li>
<li><p>This can be <strong>set&amp;forget solution</strong> (but check the caveats section) that will update every supported application</p>
</li>
<li><p>You can use <strong>whitelist</strong> or <strong>blacklist</strong> approach for updating the apps (a.k.a. not all apps have to be updated)</p>
</li>
<li><p>The <strong>update of applications will be carried out in waves</strong> (controlled via ring groups). This way, you will have the opportunity to stop the update process or exclude problematic apps if any problems arise in the testing ring group.</p>
</li>
</ul>
<h3 id="heading-caveats">Caveats</h3>
<ul>
<li><p>Only installed software that <strong>WinGet</strong> "knows", can be updated (to get known packages, run <code>winget list</code> command on your command)</p>
</li>
<li><p>You need to <strong>trust WinGet packages</strong> (<a target="_blank" href="https://github.com/microsoft/winget-pkgs/blob/master/Moderation.md">who are the WinGet maintainers</a>?). At least to have a hassle-free solution. Another option would be to have some automated check for the WinGet package manifest files to see whether installers are downloading from the correct URLs</p>
<ul>
<li>Unfortunately, there is nothing like "verified" publishers in the WinGet still</li>
</ul>
</li>
<li><p>Not all WinGet packages are top-notch <strong>quality</strong>. It happened to me that some installer wasn't running silently. Instead, GUI was shown, which could be confusing for your users and can lead to not updating the apps. Also in some cases, it can happen that the old version is not uninstalled automatically</p>
</li>
<li><p>Because set&amp;forget ideology, there is no option to control the version apps should be updated to. That's the main reason for using "ring" groups. To be able to react to potential issues before main groups of users get the update.</p>
</li>
</ul>
<hr />
<h1 id="heading-requirements">Requirements</h1>
<ul>
<li><p>Be an <strong>Intune administrator</strong></p>
<ul>
<li>to be able to create and deploy packages</li>
</ul>
</li>
<li><p>Be an <strong>Azure administrator</strong></p>
<ul>
<li><p>to be able to create groups and Azure Automation Runbook and to assign the Graph API permissions</p>
<ul>
<li>Azure Automation can be replaced by on-premises Scheduled task</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>Windows clients</strong> managed through the <strong>Intune</strong></p>
</li>
<li><p>Be OK to use the community <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate">Winget-AutoUpdate</a> tool</p>
<ul>
<li>can be done without it, but this offers several nice features like <a target="_blank" href="https://github.com/Romanitho/Winget-Install?tab=readme-ov-file#custom-mods">Mods</a>, etc and you can easily check its source code to make sure it is clean</li>
</ul>
</li>
</ul>
<hr />
<h1 id="heading-steps-to-make-this-solution-work">Steps to make this solution work</h1>
<h2 id="heading-set-up-azure-ring-groups-and-automation-to-manage-them">Set up Azure "ring" groups and Automation to manage them</h2>
<p>Follow the steps mentioned in the post <a target="_blank" href="https://doitpsway.com/how-to-create-your-own-autopatch-like-ring-groups-in-azure-using-azure-automation-and-powershell">How to create your own autopatch-like "ring" groups in Azure using Azure Automation and PowerShell</a> to create all required Azure groups ("root" and "ring") plus the automation (that will manage the group members based on the given criteria).</p>
<p>For our particular use case check details below about what groups you should create.</p>
<h3 id="heading-root-group">Root group</h3>
<p>Create Azure group with just a few devices for testing purposes only.</p>
<p>When you test this solution enough, fill this group with rest of your client devices.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The only important thing is that you <strong>can't mix user and device accounts in this group</strong>! But the group can contain other groups (recursive search is supported)</div>
</div>

<h3 id="heading-ring-groups">Ring groups</h3>
<p>A "ring" groups will be used for assignment when deploying the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate">Winget-AutoUpdate</a> updating tool package.</p>
<p>Every package will deploy the tool with a different update frequency configuration.</p>
<p>This is how we make the update process gradual 😎!</p>
<p>There are several update frequency settings available right now (<code>Daily</code> (Default), <code>BiDaily</code>, <code>Weekly</code>, <code>BiWeekly</code>, <code>Monthly</code> or <code>Never</code> (check the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate?tab=readme-ov-file#advanced-installation"><strong>UpdatesInterval</strong> parameter for more details</a><strong>)</strong></p>
<p>Hence if for example, you want to test the updates in four waves (daily, weekly, biweekly, monthly), create four Azure "ring" groups.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709020784407/37f26b52-5dc6-4063-a5d5-9c1f13da1c7d.png" alt class="image--center mx-auto" /></p>
<p>It is a good idea to specify the update frequency in the group name, to make it easier to identify the group purpose later (TIP: the group description will be automatically set using our automation)</p>
<p>Create the groups as <strong>security</strong> and with <strong>manually assigned members</strong> (don't add any members now!).</p>
<p>Make a note of each created group and its ID. We will need this when setting up the Azure automation.</p>
<hr />
<h2 id="heading-create-amp-deploy-intune-win32app-packages-for-deploying-the-winget-autoupdate-tool">Create &amp; deploy Intune Win32App packages for deploying the Winget-AutoUpdate tool</h2>
<p>One last step in our journey is to create Intune apps for deploying the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate">Winget-AutoUpdate</a> tool and assign these apps to our "ring" groups.</p>
<p><strong>One Intune app should be created for every update ring</strong>. The only thing these apps will differ is the set update frequency.</p>
<p>Thanks to the Winget-AutoUpdate tool parameter <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate?tab=readme-ov-file#when-does-the-script-run">UpdatesInterval</a> we can have only one physical Win32App package and just use different Intune installation commands 😎.</p>
<h3 id="heading-create-the-win32app-package">Create the Win32App package</h3>
<ul>
<li><p>Download the newest stable version of the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate">Winget-AutoUpdate</a> tool (<a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate/releases">WAU.zip</a>).</p>
</li>
<li><p>Unzip <code>WAU.zip</code> to the new folder <code>Winget-AutoUpdate_AllButExcluded</code></p>
</li>
<li><p>Move file <code>excluded_apps.txt</code> out to a different folder</p>
</li>
<li><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709308361572/6541c909-2a4c-4a0f-a18c-2740dd679baf.png" alt class="image--center mx-auto" /></p>
</li>
<li><p>Create <code>Winget-AutoUpdate_AllButExcluded.intunewin</code> package from the <code>Winget-AutoUpdate_AllButExcluded</code> folder (<a target="_blank" href="https://www.cloudfronts.com/blog/azure-office365/how-to-create-an-intunewin-file/">how?</a>)</p>
</li>
</ul>
<h3 id="heading-create-intune-app">Create Intune app</h3>
<ul>
<li><p>Create new <code>Windows app (win32)</code> and as a package use <code>Winget-AutoUpdate_AllButExcluded.intunewin</code> created in the previous step</p>
</li>
<li><p>Package name should contain used update frequency (for example <code>Winget-AutoUpdate_AllButExcluded (BiWeekly)</code>) for easy differentiation</p>
</li>
<li><p><strong>The install command</strong> will be: <code>"%systemroot%\sysnative\WindowsPowerShell\v1.0\powershell.exe" -noprofile -executionpolicy bypass -file "Winget-AutoUpdate-Install.ps1" -Silent -DoNotUpdate -DisableWAUAutoUpdate -InstallUserContext -NotificationLevel &lt;NotificationLevel&gt; -UpdatesInterval &lt;UpdatesInterval&gt; -ListPath &lt;PathToYourBlackListFile&gt;</code></p>
<ul>
<li><p><code>&lt;NotificationLevel&gt;</code> replace by <code>Full</code>, <code>Success</code> or <code>None</code>. At least from the start when there will be a lot of apps updated it is a good idea to select <code>None</code> to minimize the users distraction</p>
</li>
<li><p><code>&lt;UpdatesInterval&gt;</code> replace by <code>Daily</code>, <code>BiDaily</code>, <code>Weekly</code>, <code>BiWeekly</code> or <code>Monthly</code> based on the "ring" package you are creating right now</p>
</li>
<li><p><code>&lt;PathToYourBlackListFile&gt;</code> replace by URL where the <code>excluded_apps.txt</code> file is hosted (check Tips &amp; tricks section for more details)</p>
</li>
<li><div data-node-type="callout">
  <div data-node-type="callout-emoji">💡</div>
  <div data-node-type="callout-text">There are other useful parameters like <code>ModsPath</code></div>
  </div>
</li>
</ul>
</li>
<li><p><strong>The Uninstall command</strong> will be <code>"%systemroot%\sysnative\WindowsPowerShell\v1.0\powershell.exe" -noprofile -executionpolicy bypass -file "C:\ProgramData\Winget-AutoUpdate\WAU-Uninstall.ps1"</code></p>
</li>
<li><p><strong>The detection script</strong> will be similar to this</p>
<ul>
<li><pre><code class="lang-powershell">      <span class="hljs-comment"># required values</span>
      <span class="hljs-variable">$RequiredUpdatesInterval</span> = <span class="hljs-string">"&lt;replace&gt;"</span> <span class="hljs-comment"># "Daily", "BiDaily", "Weekly", "BiWeekly", "Monthly", "Never"</span>
      <span class="hljs-variable">$RequiredDisplayVersion</span> = <span class="hljs-string">"&lt;replace&gt;"</span> <span class="hljs-comment"># 1.19.1</span>
      <span class="hljs-variable">$RequiredNotificationLevel</span> = <span class="hljs-string">"&lt;replace&gt;"</span> <span class="hljs-comment"># "Full", "SuccessOnly", "None"</span>

      <span class="hljs-built_in">Start-Sleep</span> <span class="hljs-number">30</span>

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

      <span class="hljs-comment">#region configured values</span>
      <span class="hljs-variable">$WAURegPath</span> = <span class="hljs-string">"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Winget-AutoUpdate\"</span>

      <span class="hljs-keyword">try</span> {
          <span class="hljs-variable">$InstallLocation</span> = <span class="hljs-built_in">Get-ItemPropertyValue</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$WAURegPath</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"InstallLocation"</span>
          <span class="hljs-variable">$UpdatesInterval</span> = <span class="hljs-built_in">Get-ItemPropertyValue</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$WAURegPath</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"WAU_UpdatesInterval"</span>
          <span class="hljs-variable">$DisplayVersion</span> = <span class="hljs-built_in">Get-ItemPropertyValue</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$WAURegPath</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"DisplayVersion"</span>
          <span class="hljs-variable">$NotificationLevel</span> = <span class="hljs-built_in">Get-ItemPropertyValue</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$WAURegPath</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"WAU_NotificationLevel"</span>
      } <span class="hljs-keyword">catch</span> {
          <span class="hljs-keyword">throw</span> <span class="hljs-string">"WAU not installed"</span>
      }

      <span class="hljs-keyword">try</span> {
          <span class="hljs-variable">$UseWhiteList</span> = <span class="hljs-built_in">Get-ItemPropertyValue</span> <span class="hljs-literal">-Path</span> <span class="hljs-variable">$WAURegPath</span> <span class="hljs-literal">-Name</span> <span class="hljs-string">"WAU_UseWhiteList"</span>
      } <span class="hljs-keyword">catch</span> {
          <span class="hljs-variable">$UseWhiteList</span> = <span class="hljs-variable">$false</span>
      }
      <span class="hljs-comment">#endregion configured values</span>

      <span class="hljs-keyword">if</span> ((<span class="hljs-variable">$UpdatesInterval</span> <span class="hljs-operator">-eq</span> <span class="hljs-variable">$RequiredUpdatesInterval</span>) <span class="hljs-operator">-and</span> (<span class="hljs-variable">$NotificationLevel</span> <span class="hljs-operator">-eq</span> <span class="hljs-variable">$RequiredNotificationLevel</span>) <span class="hljs-operator">-and</span> !<span class="hljs-variable">$UseWhiteList</span> <span class="hljs-operator">-and</span> [<span class="hljs-type">version</span>]<span class="hljs-variable">$DisplayVersion</span> <span class="hljs-operator">-ge</span> [<span class="hljs-type">version</span>]<span class="hljs-variable">$RequiredDisplayVersion</span> <span class="hljs-operator">-and</span> <span class="hljs-variable">$InstallLocation</span> <span class="hljs-operator">-and</span> (<span class="hljs-built_in">Test-Path</span> <span class="hljs-variable">$InstallLocation</span>) <span class="hljs-operator">-and</span> (<span class="hljs-built_in">Get-ScheduledTask</span> <span class="hljs-literal">-TaskName</span> <span class="hljs-string">"Winget-AutoUpdate"</span>) <span class="hljs-operator">-and</span> (<span class="hljs-built_in">Get-ScheduledTask</span> <span class="hljs-literal">-TaskName</span> <span class="hljs-string">"Winget-AutoUpdate-Notify"</span>)) {
          <span class="hljs-keyword">return</span> <span class="hljs-string">"WAU is installed"</span>
      } <span class="hljs-keyword">else</span> {
          <span class="hljs-keyword">throw</span> <span class="hljs-string">"WAU not installed"</span>
      }
</code></pre>
</li>
<li><p>Don't forget to <strong>replace the following variables</strong> to match your setup <code>$RequiredUpdatesInterval</code>, <code>$RequiredDisplayVersion</code>, <code>$RequiredNotificationLevel</code>!</p>
</li>
</ul>
</li>
</ul>
<p>At the end of the day, you should have something similar in your Intune app list</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709305609552/e13eb657-ef0d-4a61-ba1f-d79d9965395e.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-deploy-intune-app">Deploy Intune app</h3>
<p>Now when all necessary Intune apps are created, assign each of them as required to the corresponding Azure "ring" group.</p>
<p>In my case app <code>Winget-AutoUpdate_AllButExcluded (BiWeekly)</code> is assigned to the group <code>_testo_ring_biweekly</code>, Winget-AutoUpdate_AllButExcluded (Monthly) app is assigned to the group <code>_testo_ring_monthly</code> etc.</p>
<hr />
<h1 id="heading-summary">Summary</h1>
<p>If you successfully finished all the previous steps, you should now have a working solution for updating your company clients apps.</p>
<p>Created Azure Automation Runbook will rebalance your clients across the Azure ring groups and based on this, WinGet-AutoUpdate Intune package will be installed on the group members and will update the client apps according the schedule set in the package installation command.</p>
<hr />
<h1 id="heading-tips-amp-tricks">Tips &amp; tricks</h1>
<h2 id="heading-saving-app-exclude-list-to-the-cloud">Saving app exclude list to the cloud</h2>
<p>Winget-AutoUpdate has one super cool feature and that is the option to place the exclude list (<code>excluded_apps.txt</code>) online (check the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate?tab=readme-ov-file#advanced-installation">ListPath parameter</a>). This way whenever an update is being run, the exclude list will be downloaded hence the changes made in this list are immediately applied. This is very useful in case, we have a problem with some app update. Just exclude the app by adding its WinGet ID to this list and remove it once again when a new, fixed version is available in the WinGet repository.</p>
<p>If you want to save your <code>excluded_apps.txt</code> to the Azure DevOps public repository this is how you can find the URL for ListPath parameter:</p>
<ul>
<li><p>Place the <code>excluded_apps.txt</code> file to your <strong>public</strong> DevOps repository</p>
</li>
<li><p>In your browser open the <strong>folder</strong> (<code>Repos\Files\&lt;Repository&gt;\...</code>) where you store the file (URL has to target folder, not directly the <code>excluded_apps.txt</code> !)</p>
</li>
<li><p>Open browser Developer tools (F12)</p>
</li>
<li><p>Search for "https://dev.azure.com/&lt;nameOfYourOrganization&gt;"</p>
</li>
<li><p>URL you are looking for should be in this format <code>https://dev.azure.com/&lt;nameOfYourOrganization&gt;/&lt;projectId&gt;/_apis/git/repositories/&lt;repositoryId&gt;/Items?path=/</code> (in case the file is in the repository root)</p>
</li>
<li><p>Use found URL for <code>ListPath</code> parameter</p>
</li>
</ul>
<h2 id="heading-what-should-i-do-when-the-updated-app-version-causes-troubles">What should I do when the updated app version causes troubles</h2>
<p>If you are hosting <code>excluded_apps.txt</code> "in the cloud" (public git repository, azure blob storage, etc), just add the Winget ID of this specific app to this file.</p>
<ul>
<li><p>Winget-AutoUpdate tool will automatically skip this app when invoked next time.</p>
</li>
<li><p>When the new "fixed" app version is released. Remove the ID from the <code>excluded_apps.txt</code> file.</p>
</li>
</ul>
<p>According to the affected clients. It depends on the situation, your environment and solution can vary. But in general, you need to remove the app and install the last working one.</p>
<h2 id="heading-using-wildcards-in-the-exclusion-list">Using wildcards in the exclusion list</h2>
<p>In your <code>excluded_apps.txt</code> you can use wildcard <code>*</code> to exclude all matching apps.</p>
<p>This feature is available since the <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate/releases/tag/v1.20.0">1.20.0 version of the Winget-AutoUpdate tool</a>.</p>
<h2 id="heading-debugging-the-app-updates">Debugging the app updates</h2>
<p>Check the logs <a target="_blank" href="https://github.com/Romanitho/Winget-AutoUpdate?tab=readme-ov-file#log-location">https://github.com/Romanitho/Winget-AutoUpdate?tab=readme-ov-file#log-location</a></p>
]]></content:encoded></item><item><title><![CDATA[How to create your own autopatch-like "ring" groups in Azure using Azure Automation and PowerShell]]></title><description><![CDATA[I like the idea of Autopatch ring groups where you have one "root" group, whose members are distributed across several "ring" groups based on a given percentage proportion.
And because I needed the same thing for my gradual applications update automa...]]></description><link>https://doitpshway.com/how-to-create-your-own-autopatch-like-ring-groups-in-azure-using-azure-automation-and-powershell</link><guid isPermaLink="true">https://doitpshway.com/how-to-create-your-own-autopatch-like-ring-groups-in-azure-using-azure-automation-and-powershell</guid><category><![CDATA[Azure]]></category><category><![CDATA[azure runbook]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[automation]]></category><category><![CDATA[group]]></category><dc:creator><![CDATA[Ondrej Sebela]]></dc:creator><pubDate>Sat, 09 Mar 2024 12:25:46 GMT</pubDate><content:encoded><![CDATA[<p>I like the idea of Autopatch ring groups where you have one "root" group, whose members are distributed across several "ring" groups based on a given percentage proportion.</p>
<p>And because I needed the same thing for my <a target="_blank" href="https://doitpsway.com/gradual-update-of-all-applications-using-winget-and-custom-azure-ring-groups">gradual applications update automation</a> I've created my own "ring" groups solution.</p>
<p><strong>OK, so what will be the result of this post?</strong></p>
<p>One main Azure "root" group whose members will be distributed on schedule across several Azure "ring" groups by Azure Automation Runbook that will run my function <code>Set-AzureRingGroup</code> (part of my module <a target="_blank" href="https://www.powershellgallery.com/packages/AzureGroupStuff">AzureGroupStuff</a>).</p>
<p>Interested? OK, let's go! 😉</p>
<h2 id="heading-create-azure-root-group-ring-groups">Create Azure "root" group + "ring" groups</h2>
<h3 id="heading-root-group">Root group</h3>
<p>A "root" group is nothing else than an ordinary group whose members will be distributed across the "ring" groups based on a defined percentage proportion.</p>
<p>Therefore you can use any existing group, or create a new one.</p>
<p>Group should be of <strong>security</strong> type!</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The only important thing is that you <strong>can't mix user and device accounts in this group</strong>! But the group can contain other groups (recursive search is supported)</div>
</div>

<h3 id="heading-ring-groups">Ring groups</h3>
<p>A "ring" group is where members are managed automatically through the created Azure Automation Runbook.</p>
<p>Members are picked from the "root" group and distributed across the "ring" groups based on the percentage proportion defined in the Runbook code. The group members are reorganized when needed.</p>
<p>Groups should be of <strong>security</strong> type with <strong>manually assigned members</strong>. <strong>Don't add</strong> any members now!</p>
<p>So create as many groups as you need. Make a note of each created group and its ID. We will need this when setting up the Azure automation later.</p>
<p>It is a good idea to specify the group purpose in the group name (ring1, ring2,...), to make it easier to identify the group purpose later (TIP: the group description will be automatically set using our automation)</p>
<hr />
<h2 id="heading-create-amp-set-azure-automation-for-managing-ring-group-members">Create &amp; set Azure automation for managing "ring" group members</h2>
<p>To keep members in our "ring" groups up to date, we need some automation that will do the rebalancing for us. This is where Azure Automation steps in.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">You can use Scheduled Task instead of Azure Automation Runbook if you want to save a few bucks</div>
</div>

<h3 id="heading-create-an-azure-automation-account">Create an Azure Automation Account</h3>
<p>How to create an Azure Automation Account is outside the scope of this article, but you can follow the <a target="_blank" href="https://learn.microsoft.com/en-us/azure/automation/quickstarts/create-azure-automation-account-portal">official documentation</a>.</p>
<h3 id="heading-create-runbook">Create Runbook</h3>
<p>Inside your Automation Account create a new runbook. Choose <strong>PowerShell</strong> type and <strong>5.1</strong> as a runtime version.</p>
<p>Once created, open the Runbook, choose <code>Edit</code> &gt; <code>Edit in portal</code> and insert the code below</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709293622231/7d3da8ba-a664-4375-b628-ed638e08c920.png" alt class="image--center mx-auto" /></p>
<pre><code class="lang-powershell"><span class="hljs-comment">#requires -modules AzureGroupStuff, MSGraphStuff, Microsoft.Graph.Authentication, Microsoft.Graph.Groups, Microsoft.Graph.DirectoryObjects, Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement</span>

<span class="hljs-comment"># group whose members will be distributed between ring groups</span>
<span class="hljs-variable">$rootGroup</span> = <span class="hljs-string">'IDofYOURrootGROUP'</span>
<span class="hljs-comment"># ring groups configuration</span>
<span class="hljs-variable">$ringGroupConfig</span> = [<span class="hljs-type">ordered</span>]<span class="hljs-selector-tag">@</span>{
    <span class="hljs-comment"># automatically set members</span>
    <span class="hljs-string">'IDofYOURring1GROUP'</span> = <span class="hljs-number">10</span> <span class="hljs-comment"># testo_ring_daily</span>
    <span class="hljs-string">'IDofYOURring2GROUP'</span> = <span class="hljs-number">10</span> <span class="hljs-comment"># testo_ring_weekly</span>
    <span class="hljs-string">'IDofYOURring3GROUP'</span> = <span class="hljs-number">20</span> <span class="hljs-comment"># testo_ring_biweekly</span>
    <span class="hljs-string">'IDofYOURring4GROUP'</span> = <span class="hljs-number">60</span> <span class="hljs-comment"># testo_ring_monthly</span>
}

<span class="hljs-built_in">Connect-MgGraph</span> <span class="hljs-literal">-Identity</span> <span class="hljs-literal">-NoWelcome</span>

<span class="hljs-comment"># Set-AzureRingGroup is part of the AzureGroupStuff module</span>
<span class="hljs-built_in">Set-AzureRingGroup</span> <span class="hljs-literal">-rootGroup</span> <span class="hljs-variable">$rootGroup</span> <span class="hljs-literal">-ringGroupConfig</span> <span class="hljs-variable">$ringGroupConfig</span> <span class="hljs-literal">-forceRecalculate</span> <span class="hljs-literal">-skipUnderscoreInNameCheck</span> <span class="hljs-literal">-Verbose</span>
</code></pre>
<p>Don't forget to modify <code>$rootGroup</code> variable to match the ID of your "root" group.</p>
<p>Also you need to modify <code>$ringGroupConfig</code> hashtable.</p>
<p><code>Keys</code> in the hash are the IDs of the "ring" groups created earlier and <code>values</code> are integers representing the percentage of how many members of the "root" group should be placed in this "ring" group.</p>
<p>The sum of the <code>values</code> must be obviously 100 in total.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709027418322/78110967-0264-4b5a-b211-4c997ec2730b.png" alt class="image--center mx-auto" /></p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">There is also an option to have a "ring" group with manually set members (ideal for early adopters). Check <code>firstRingGroupMembersSetManually</code> parameter of the <code>Set-AzureRingGroup</code> function for more details.</div>
</div>

<p>Used function <code>Set-AzureRingGroup</code> is part of my module <a target="_blank" href="https://www.powershellgallery.com/packages/AzureGroupStuff">AzureGroupStuff</a> and I really encourage you to check its help to better understand how it works and what option does it offer.</p>
<h3 id="heading-schedule-the-runbook">Schedule the Runbook</h3>
<p>Schedule the Runbook to run regularly using the button <code>Link to schedule</code> in your Runbook pane</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709024383823/23ae99ab-e45a-4da6-aa45-3b81decb8178.png" alt class="image--center mx-auto" /></p>
<p>Create a new schedule to run every three hours or so (based on your needs).</p>
<h3 id="heading-assign-required-permissions-to-the-automation-account-managed-identity">Assign required permissions to the Automation Account Managed Identity</h3>
<p>Every Automation Account has automatically created <code>Managed Identity</code> (enterprise application a.k.a. service principal). It allows the runbook to access and perform actions on other Azure resources without storing any credentials in the runbook code or configuration.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709027193703/7f449585-0d2c-4063-a447-936969330314.png" alt class="image--center mx-auto" /></p>
<p>For this identity to have appropriate Graph API permissions to manipulate group members, the following application permissions have to be granted.</p>
<ul>
<li><p><a target="_blank" href="http://Device.Read"><code>Device.Read</code></a><code>.All</code></p>
<ul>
<li>to be able to work with member device objects</li>
</ul>
</li>
<li><p><a target="_blank" href="http://User.Read"><code>User.Read</code></a><code>.All</code></p>
<ul>
<li>to be able to work with member user objects</li>
</ul>
</li>
<li><p><code>Group.ReadWrite.All</code></p>
<ul>
<li>to be able to set group members and description</li>
</ul>
</li>
</ul>
<p>You can do this using my function <code>Grant-AzureServicePrincipalPermission</code> (part of my module <a target="_blank" href="https://www.powershellgallery.com/packages/AzureApplicationStuff">AzureApplicationStuff</a>)</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">Grant-AzureServicePrincipalPermission</span> <span class="hljs-literal">-servicePrincipalId</span> <span class="hljs-string">'IDofYOURAUTOMATIONmanagedIDENTITY'</span> <span class="hljs-literal">-permissionType</span> application <span class="hljs-literal">-permissionList</span> <span class="hljs-built_in">Grant-AzureServicePrincipalPermission</span> <span class="hljs-literal">-servicePrincipalId</span> <span class="hljs-string">'2de94e47-895d-4780-8f8f-b803d5e243b2'</span> <span class="hljs-literal">-permissionType</span> application <span class="hljs-literal">-permissionList</span> Directory.Read.All, User.Read.All, Group.ReadWrite.All
</code></pre>
<h3 id="heading-import-required-modules-to-the-automation-account">Import required modules to the Automation Account</h3>
<p>The following PowerShell modules need to be imported so the Runbook can run</p>
<ul>
<li><p><code>AzureGroupStuff</code></p>
</li>
<li><p><code>MSGraphStuff</code></p>
</li>
<li><p><code>Microsoft.Graph.Authentication</code></p>
</li>
<li><p><code>Microsoft.Graph.Groups</code></p>
</li>
<li><p><code>Microsoft.Graph.DirectoryObjects</code></p>
</li>
<li><p><code>Microsoft.Graph.Users</code></p>
</li>
<li><p><code>Microsoft.Graph.Identity.DirectoryManagement</code></p>
</li>
</ul>
<p>You can do this manually using <code>Add a module</code> button.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709023386795/a1428327-a1ea-4f73-9c25-2e7f7fd71c55.png" alt class="image--center mx-auto" /></p>
<p>Or using my function <code>New-AzureAutomationModule</code> (part of my <a target="_blank" href="https://www.powershellgallery.com/packages/AzureResourceStuff">AzureResourceStuff</a> module).</p>
<pre><code class="lang-powershell"><span class="hljs-string">'AzureGroupStuff'</span>, <span class="hljs-string">'MSGraphStuff'</span>, <span class="hljs-string">'Microsoft.Graph.Authentication'</span>,<span class="hljs-string">'Microsoft.Graph.Groups'</span>,<span class="hljs-string">'Microsoft.Graph.DirectoryObjects'</span>,<span class="hljs-string">'Microsoft.Graph.Users'</span>,<span class="hljs-string">'Microsoft.Graph.Identity.DirectoryManagement'</span> | % {
    <span class="hljs-built_in">New-AzureAutomationModule</span> <span class="hljs-literal">-moduleName</span> <span class="hljs-variable">$_</span> <span class="hljs-literal">-resourceGroupName</span> yourRESOURCEgroupNAME <span class="hljs-literal">-automationAccountName</span> yourAUTOMATIONaccountNAME
}
</code></pre>
<p>For more details check my post <a target="_blank" href="https://doitpsway.com/import-new-or-update-existing-powershell-module-including-its-dependencies-into-azure-automation-account-using-powershell">Import new (or update existing) PowerShell module (including its dependencies!) into Azure Automation Account using PowerShell</a></p>
<h2 id="heading-add-members-to-the-root-group">Add members to the "root" group</h2>
<p>In case you didn't do so already. Add some test users or devices to your "root" group that should be targeted by this updating solution.</p>
<div data-node-type="callout">
<div data-node-type="callout-emoji">💥</div>
<div data-node-type="callout-text"><strong>It is a good idea to select just a few testing accounts right now and add more later once you test this solution!</strong></div>
</div>

<h2 id="heading-set-ring-groups-members-by-running-the-runbook">Set "ring" groups members by running the Runbook</h2>
<p>Run your Runbook using the <code>Start</code> button.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1709024819885/ef71c2ff-8cd8-46b8-a9d5-5ebbdfc6ce23.png" alt class="image--center mx-auto" /></p>
<p>When the Runbook ends you should see members from the "root" group added to the "ring" groups based on the percent ratio specified in the Runbook code <code>$ringGroupConfig</code> hashtable.</p>
<p>And that's it 👍.</p>
<p>From now on, members of the "root" group will be automatically distributed across your "ring" groups whenever the Automation will run.</p>
]]></content:encoded></item></channel></rss>