<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>charlie.tools</title><description>PowerShell, SPFx, Microsoft 365, and homelab notes from Charlie Vogt.</description><link>https://charlie.tools/</link><language>en-us</language><item><title>Auditing Microsoft 365 admin role assignments with Graph PowerShell</title><link>https://charlie.tools/posts/audit-m365-admin-roles-with-graph/</link><guid isPermaLink="true">https://charlie.tools/posts/audit-m365-admin-roles-with-graph/</guid><description>A five-minute script that pulls every directory role assignment in your tenant, including PIM-eligible and group-based assignments, and writes a clean CSV your security team will actually read.</description><pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you inherited a tenant that has been around for a while, you already know the pain: the Entra admin center shows &lt;em&gt;active&lt;/em&gt; assignments, but it hides group-based ones behind an extra click and sends you to a second blade for PIM-eligible roles. For a quarterly review that needs to go to auditors, you want one CSV.&lt;/p&gt;
&lt;p&gt;Here is the Graph PowerShell script I run the first week of every quarter. It walks every directory role, resolves group members, and flags which assignments are PIM-eligible vs. active.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;#Requires -Modules Microsoft.Graph.Identity.Governance, Microsoft.Graph.Groups

[CmdletBinding()]
param(
    [string]$OutputPath = &quot;./admin-roles-$(Get-Date -Format &apos;yyyy-MM-dd&apos;).csv&quot;
)

Connect-MgGraph -Scopes &apos;RoleManagement.Read.Directory&apos;, &apos;Directory.Read.All&apos; -NoWelcome

$roles = Get-MgRoleManagementDirectoryRoleDefinition -All
$results = foreach ($role in $roles) {
    $active = Get-MgRoleManagementDirectoryRoleAssignment `
        -Filter &quot;roleDefinitionId eq &apos;$($role.Id)&apos;&quot; -ExpandProperty Principal -All
    $eligible = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
        -Filter &quot;roleDefinitionId eq &apos;$($role.Id)&apos;&quot; -ExpandProperty Principal -All

    foreach ($a in @($active) + @($eligible)) {
        [pscustomobject]@{
            Role          = $role.DisplayName
            AssignmentKind = if ($a.PSObject.TypeNames -match &apos;Eligibility&apos;) { &apos;Eligible&apos; } else { &apos;Active&apos; }
            PrincipalType = $a.Principal.AdditionalProperties.&apos;@odata.type&apos; -replace &apos;#microsoft.graph.&apos;, &apos;&apos;
            Principal     = $a.Principal.AdditionalProperties.displayName
            UPN           = $a.Principal.AdditionalProperties.userPrincipalName
            Scope         = $a.DirectoryScopeId
        }
    }
}

$results | Sort-Object Role, Principal | Export-Csv -NoTypeInformation $OutputPath
Write-Host &quot;Wrote $($results.Count) rows to $OutputPath&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things that tripped me up the first time I wrote this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Role assignments to groups show up with &lt;code&gt;PrincipalType = group&lt;/code&gt;. You still need to walk the members — I handle that in a follow-up pass, which I will cover in another post.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;DirectoryScopeId&lt;/code&gt; is &lt;code&gt;/&lt;/code&gt; for tenant-wide, otherwise it is an administrative unit. If your org uses AUs, keep that column; if not, drop it to make the CSV easier to read.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I run this unattended via a scheduled task with a certificate-auth app registration. If there is interest, I will write up the app registration setup next.&lt;/p&gt;
</content:encoded><category>PowerShell</category><category>Microsoft 365</category><category>Entra ID</category><category>Security</category></item><item><title>Using the PnP reusable property pane controls in SPFx 1.20</title><link>https://charlie.tools/posts/spfx-property-pane-pnp-controls/</link><guid isPermaLink="true">https://charlie.tools/posts/spfx-property-pane-pnp-controls/</guid><description>The out-of-the-box SPFx property pane only gets you so far. Here is a lean setup for adding a people picker, a collection data control, and a rich text field using the PnP controls library.</description><pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every SPFx web part I ship eventually grows past text fields and toggles, and the default property pane controls run out of runway fast. The PnP reusable property pane controls fill the gap — there is a people picker, a collection data editor, a rich text area, and a dozen others that the built-in controls do not cover.&lt;/p&gt;
&lt;p&gt;Add the package to your SPFx project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @pnp/spfx-property-controls --save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then drop the controls into &lt;code&gt;getPropertyPaneConfiguration&lt;/code&gt;. Here is a minimal people picker wired up to a web part property.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { PropertyPanePeoplePicker, PrincipalType } from &apos;@pnp/spfx-property-controls/lib/PropertyPanePeoplePicker&apos;;
import type { IPropertyPaneConfiguration } from &apos;@microsoft/sp-property-pane&apos;;

export interface IMyWebPartProps {
  approvers: { fullName: string; login: string }[];
}

protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
  return {
    pages: [
      {
        header: { description: &apos;Approval routing&apos; },
        groups: [
          {
            groupName: &apos;Approvers&apos;,
            groupFields: [
              PropertyPanePeoplePicker(&apos;approvers&apos;, {
                label: &apos;Select approvers&apos;,
                initialData: this.properties.approvers ?? [],
                allowDuplicate: false,
                principalType: [PrincipalType.User, PrincipalType.SharePointGroup],
                context: this.context,
                onPropertyChange: this.onPropertyPaneFieldChanged.bind(this),
                properties: this.properties,
                onGetErrorMessage: null,
                deferredValidationTime: 200,
                key: &apos;approversPicker&apos;,
              }),
            ],
          },
        ],
      },
    ],
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few notes from production use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;context&lt;/code&gt; must be the full &lt;code&gt;WebPartContext&lt;/code&gt;, not &lt;code&gt;context.msGraphClientFactory&lt;/code&gt;. The control needs the site URL to resolve SharePoint groups.&lt;/li&gt;
&lt;li&gt;Setting &lt;code&gt;principalType&lt;/code&gt; to only &lt;code&gt;PrincipalType.User&lt;/code&gt; is worth it for approval scenarios — letting users pick a group silently hides who gets the notification.&lt;/li&gt;
&lt;li&gt;If you are in a multi-geo tenant, test against a satellite site. The picker is scoped to the web, and cross-geo group resolution occasionally throws silent timeouts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full control catalogue lives at &lt;a href=&quot;https://pnp.github.io/sp-dev-fx-property-controls/&quot;&gt;pnp.github.io/sp-dev-fx-property-controls&lt;/a&gt;. The collection data control is the other one I reach for constantly — worth its own post.&lt;/p&gt;
</content:encoded><category>SPFx</category><category>SharePoint</category><category>TypeScript</category><category>React</category></item><item><title>Enforcing conditional access for guests without breaking B2B invites</title><link>https://charlie.tools/posts/conditional-access-guests-without-breaking-b2b/</link><guid isPermaLink="true">https://charlie.tools/posts/conditional-access-guests-without-breaking-b2b/</guid><description>Lock down guest access with conditional access policies that require MFA and compliant devices, without wrecking the B2B invite redemption flow. The trick is in the include/exclude targeting.</description><pubDate>Fri, 27 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The first time I rolled out a &quot;guests must MFA&quot; conditional access policy, I locked out every new B2B invitee. They hit the redemption URL, were challenged for MFA against their &lt;em&gt;home&lt;/em&gt; tenant, passed, then got blocked by &lt;em&gt;our&lt;/em&gt; policy because they had not yet registered MFA in our directory. The fix is not complicated, but it is counter-intuitive.&lt;/p&gt;
&lt;p&gt;The policy target should be &lt;strong&gt;All guest and external users&lt;/strong&gt;, but you need to &lt;strong&gt;exclude&lt;/strong&gt; the &lt;code&gt;b2b-invitation-redemption&lt;/code&gt; user action &lt;em&gt;and&lt;/em&gt; carve out the Azure AD service principal that handles invite sign-ins. Here is the target section I ship:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;displayName&quot;: &quot;Require MFA for guests&quot;,
  &quot;state&quot;: &quot;enabled&quot;,
  &quot;conditions&quot;: {
    &quot;users&quot;: {
      &quot;includeGuestsOrExternalUsers&quot;: {
        &quot;guestOrExternalUserTypes&quot;: &quot;b2bCollaborationGuest,b2bCollaborationMember&quot;,
        &quot;externalTenants&quot;: { &quot;membershipKind&quot;: &quot;all&quot; }
      },
      &quot;excludeUsers&quot;: [&quot;&amp;lt;your-break-glass-account-object-id&amp;gt;&quot;]
    },
    &quot;applications&quot;: {
      &quot;includeApplications&quot;: [&quot;All&quot;],
      &quot;excludeUserActions&quot;: [&quot;urn:user:registersecurityinfo&quot;]
    }
  },
  &quot;grantControls&quot;: {
    &quot;operator&quot;: &quot;AND&quot;,
    &quot;builtInControls&quot;: [&quot;mfa&quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things I wish someone had told me:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Do not exclude the B2B invitation redemption user action.&lt;/strong&gt; It is tempting, because that is what is failing. But the redemption itself does not need a grant control — and if a guest is already in your directory from a previous invite, you still want MFA on the redeemed session. Excluding &lt;code&gt;registersecurityinfo&lt;/code&gt; is enough to let them complete MFA registration in the target tenant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scope to &lt;code&gt;b2bCollaborationGuest&lt;/code&gt; and &lt;code&gt;b2bCollaborationMember&lt;/code&gt;&lt;/strong&gt; rather than the old &lt;code&gt;includeUsers: &quot;GuestsOrExternalUsers&quot;&lt;/code&gt;. The newer schema is more granular and lets you write separate policies for B2B, B2C, and service provider users without a dozen excludes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use report-only mode for at least 72 hours.&lt;/strong&gt; Every tenant has a long-tail of guest sign-ins from external sharing that nobody has thought about in two years. Report-only catches them before they become support tickets.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you are rolling this out via &lt;code&gt;New-MgIdentityConditionalAccessPolicy&lt;/code&gt;, let me know — the PowerShell version has one more gotcha around the external tenants schema that is worth a separate writeup.&lt;/p&gt;
</content:encoded><category>Microsoft 365</category><category>Entra ID</category><category>Conditional Access</category><category>Security</category></item><item><title>Proxmox ZFS replication to a cold offsite node over Tailscale</title><link>https://charlie.tools/posts/proxmox-zfs-replication-offsite/</link><guid isPermaLink="true">https://charlie.tools/posts/proxmox-zfs-replication-offsite/</guid><description>Turning a mini-PC at my parents house into a cold standby for the homelab. ZFS send/receive over Tailscale, orchestrated with a Proxmox replication job and a boring shell script.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My homelab has been a single-node Proxmox box for too long. A power surge last summer took it offline for three days, and while nothing was lost, I realized the only copy of every family photo I have digitized is on that ZFS pool. Time to fix that.&lt;/p&gt;
&lt;p&gt;The plan: a fanless mini-PC at my parents&apos; house, on Tailscale, pulling ZFS snapshots nightly. It does not run any VMs — it is just a cold target. If the primary dies, I restore to new hardware from the offsite pool.&lt;/p&gt;
&lt;p&gt;The trick with Proxmox&apos;s built-in replication is that it expects both nodes to be in the same cluster. I do not want a two-node cluster across a consumer internet link, so I drive replication with a shell script and &lt;code&gt;zfs send | ssh | zfs receive&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
set -euo pipefail

POOL=&quot;rpool&quot;
DATASETS=(&quot;rpool/data/vm-100-disk-0&quot; &quot;rpool/data/vm-101-disk-0&quot; &quot;rpool/photos&quot;)
REMOTE_HOST=&quot;offsite.tailnet&quot;
REMOTE_POOL=&quot;backup&quot;
SNAPSHOT_TAG=&quot;offsite-$(date +%Y%m%d-%H%M)&quot;

for ds in &quot;${DATASETS[@]}&quot;; do
  echo &quot;==&amp;gt; Snapshotting $ds@$SNAPSHOT_TAG&quot;
  zfs snapshot &quot;$ds@$SNAPSHOT_TAG&quot;

  # Find the most recent common snapshot for incremental send
  LATEST_REMOTE=$(ssh &quot;$REMOTE_HOST&quot; \
    &quot;zfs list -H -t snapshot -o name -s creation ${REMOTE_POOL}/${ds#*/} 2&amp;gt;/dev/null | tail -n1&quot; \
    | sed &quot;s|^${REMOTE_POOL}/||&quot; || true)

  if [[ -n &quot;$LATEST_REMOTE&quot; ]]; then
    echo &quot;==&amp;gt; Incremental from $LATEST_REMOTE&quot;
    zfs send -i &quot;$LATEST_REMOTE&quot; &quot;$ds@$SNAPSHOT_TAG&quot; \
      | ssh &quot;$REMOTE_HOST&quot; &quot;zfs receive -F ${REMOTE_POOL}/${ds#*/}&quot;
  else
    echo &quot;==&amp;gt; Full send (first run)&quot;
    zfs send &quot;$ds@$SNAPSHOT_TAG&quot; \
      | ssh &quot;$REMOTE_HOST&quot; &quot;zfs receive -F ${REMOTE_POOL}/${ds#*/}&quot;
  fi
done

# Prune local snapshots older than 14 days (offsite keeps its own retention)
zfs list -H -t snapshot -o name | grep &apos;@offsite-&apos; | while read snap; do
  SNAP_DATE=$(echo &quot;$snap&quot; | sed -n &apos;s/.*@offsite-\([0-9]\{8\}\).*/\1/p&apos;)
  if [[ $(date -d &quot;$SNAP_DATE&quot; +%s) -lt $(date -d &apos;14 days ago&apos; +%s) ]]; then
    zfs destroy &quot;$snap&quot;
  fi
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lessons from the first month of running this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tailscale MSS clamping matters.&lt;/strong&gt; My consumer cable upload started fragmenting on long ZFS streams until I set &lt;code&gt;tailscale up --accept-dns=false --advertise-exit-node=false&lt;/code&gt; with MSS clamping in the tailnet ACL. Throughput tripled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;zfs receive -F&lt;/code&gt; is fine here but be aware it destroys any receiver-side snapshot newer than the source.&lt;/strong&gt; On a cold node that only ever receives, this is what you want. On a hot node you are failing over to, it is a foot-gun.&lt;/li&gt;
&lt;li&gt;The first full send of my photos dataset was 1.8 TB and ran for four days. Budget for that. Subsequent nightly incrementals run in under ten minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Next step: a monthly &lt;code&gt;scrub&lt;/code&gt; on the offsite node with paging to my phone if it reports errors. That is this weekend&apos;s project.&lt;/p&gt;
</content:encoded><category>Homelab</category><category>Proxmox</category><category>ZFS</category><category>Backup</category><category>Tailscale</category></item></channel></rss>