src/Private/Join-AccountLicense.ps1
|
function Join-AccountLicense { <# .SYNOPSIS Correlates on-prem AD accounts with cloud license assignments and flags the reclaim candidates. .DESCRIPTION A "reclaim candidate" is an account that is dead on-premises (disabled, OR stale beyond -StaleDays) yet still holds one or more Microsoft 365 licenses in the cloud. A never-logged-on account is treated as stale only once it has existed longer than -StaleDays, so an account provisioned ahead of a start date isn't flagged before it has had a chance to sign in. Matching order (hybrid / Entra Connect synced tenants), most to least reliable: 1. SID - AD objectSid -> Graph onPremisesSecurityIdentifier. Anchor-agnostic: independent of which sourceAnchor (objectGUID vs ms-DS-ConsistencyGuid) the tenant configured, so it matches even when the ImmutableId would not. 2. ImmutableId - AD objectGUID -> Base64 -> Graph onPremisesImmutableId. Only correct when the tenant's sourceAnchor is objectGUID (the pre-2017 default). 3. UPN - case-insensitive userPrincipalName match. Last-resort fallback; misses when the on-prem UPN differs from the cloud UPN. Accounts that are alive, unmatched, or already unlicensed are not candidates and are skipped. .OUTPUTS One PSCustomObject per reclaimable account (licenses not yet priced - see Measure-WastedSpend). #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $AdAccounts, [Parameter(Mandatory)] [object[]] $GraphUsers, [int] $StaleDays = 90, [datetime] $ReferenceDate = (Get-Date), # OU distinguished names (or single OU components) to suppress - e.g. a shared-mailbox or # litigation-hold OU whose licensed-but-disabled accounts are intentional, not candidates. [string[]] $ExcludeOu ) # Build cloud lookups. Older callers/fixtures may not carry every key, so probe each property # under Set-StrictMode rather than assuming it exists. $bySid = @{} $byImmutableId = @{} $byUpn = @{} foreach ($g in $GraphUsers) { $gSid = if ($g.PSObject.Properties.Name -contains 'OnPremisesSecurityIdentifier') { $g.OnPremisesSecurityIdentifier } else { $null } if ($gSid) { $bySid[$gSid] = $g } if ($g.OnPremisesImmutableId) { $byImmutableId[$g.OnPremisesImmutableId] = $g } if ($g.UserPrincipalName) { $byUpn[$g.UserPrincipalName.ToLowerInvariant()] = $g } } $cutoff = $ReferenceDate.AddDays(-$StaleDays) foreach ($acct in $AdAccounts) { $isDisabled = -not $acct.Enabled $daysSinceLogon = $null $isStale = $false $staleReason = $null if ($acct.LastLogonTimestamp) { $last = [datetime]$acct.LastLogonTimestamp $daysSinceLogon = [int][math]::Floor(($ReferenceDate - $last).TotalDays) if ($last -lt $cutoff) { $isStale = $true; $staleReason = "Stale ($daysSinceLogon days since last logon)" } } else { # Never logged on. A freshly-created account may simply not have signed in yet (e.g. # provisioned ahead of a start date), so give it the full stale window before judging: # only flag "no logon ever" once it has existed longer than -StaleDays. Accounts with no # creation date recorded (older fixtures) fall back to the original always-stale behaviour. $created = if ($acct.PSObject.Properties.Name -contains 'WhenCreated') { $acct.WhenCreated } else { $null } if ($created -and [datetime]$created -gt $cutoff) { $isStale = $false # too new to call stale } else { $isStale = $true $staleReason = 'No logon ever recorded' } } if (-not ($isDisabled -or $isStale)) { continue } # alive account - not a candidate # Customer-suppressed OU (intentional retention) - not a candidate. if (Test-AccountExcluded -DistinguishedName $acct.DistinguishedName -ExcludeOu $ExcludeOu) { continue } # Match to a cloud user, most reliable key first (SID -> ImmutableId -> UPN). Collect every # key's hit in priority order rather than stopping at the first, so a stale/unlicensed orphan # on a higher-priority key (e.g. a leftover cloud object that kept the synced SID) can't mask # a genuine candidate that a lower-priority key resolves to the live, licensed user. $acctSid = if ($acct.PSObject.Properties.Name -contains 'ObjectSid') { $acct.ObjectSid } else { $null } $immutable = ConvertTo-ImmutableId -ObjectGuid $acct.ObjectGuid $candidates = New-Object System.Collections.Generic.List[object] if ($acctSid -and $bySid.ContainsKey($acctSid)) { $candidates.Add([pscustomobject]@{ User = $bySid[$acctSid]; By = 'Sid' }) } if ($immutable -and $byImmutableId.ContainsKey($immutable)) { $candidates.Add([pscustomobject]@{ User = $byImmutableId[$immutable]; By = 'ImmutableId' }) } if ($acct.UserPrincipalName -and $byUpn.ContainsKey($acct.UserPrincipalName.ToLowerInvariant())) { $candidates.Add([pscustomobject]@{ User = $byUpn[$acct.UserPrincipalName.ToLowerInvariant()]; By = 'Upn' }) } if (-not $candidates.Count) { continue } # not synced / not found in cloud # Prefer the highest-priority candidate that actually holds a license; if none are licensed, # keep the highest-priority match so it is dropped below as 'already unlicensed' (unchanged). $chosen = $null foreach ($c in $candidates) { if ($c.User.AssignedLicenses -and $c.User.AssignedLicenses.Count -gt 0) { $chosen = $c; break } } if (-not $chosen) { $chosen = $candidates[0] } $match = $chosen.User $matchedBy = $chosen.By if (-not $match.AssignedLicenses -or $match.AssignedLicenses.Count -eq 0) { continue } # already unlicensed - good hygiene $reason = if ($isDisabled) { 'Disabled on-prem' } else { $staleReason } # OU path = everything after the leftmost RDN component. $ou = $null if ($acct.DistinguishedName -match '^(?:[^,]+),(.*)$') { $ou = $Matches[1] } # Domain is present on live signals (and forest scans); offline fixtures predate the field. $domain = if ($acct.PSObject.Properties.Name -contains 'Domain') { $acct.Domain } else { $null } [pscustomobject]@{ SamAccountName = $acct.SamAccountName UserPrincipalName = $acct.UserPrincipalName DisplayName = $acct.DisplayName DistinguishedName = $acct.DistinguishedName Domain = $domain OrgUnit = $ou ObjectGuid = $acct.ObjectGuid ObjectSid = $acctSid Enabled = $acct.Enabled Reason = $reason LastLogonTimestamp = $acct.LastLogonTimestamp DaysSinceLogon = $daysSinceLogon MatchedBy = $matchedBy CloudUserId = $match.Id SkuIds = @($match.AssignedLicenses) } } } |