src/Private/Invoke-LicenseReclaimScanCore.ps1
|
function Invoke-LicenseReclaimScanCore { <# .SYNOPSIS Runs the full scan pipeline and returns the assembled report object (no rendering). .DESCRIPTION Shared engine behind Invoke-OpteraLicenseScan and Get-OpteraReclaimableLicense. Collects AD + Graph data (live, or from an offline fixture directory), correlates, prices, evaluates the license gate, and returns one report object. Pure data - the public cmdlets decide how to present it and what the Free/Full gate is allowed to reveal. .OUTPUTS Report PSCustomObject: GeneratedAt, Mode, TenantId, StaleDays, Summary, Inventory, Reclaimable, License, Disclaimer #> [CmdletBinding()] param( [string] $SearchBase, [string] $Server, [switch] $Forest, [int] $StaleDays = 90, [string[]] $ExcludeOu, [string] $PriceListPath, [string] $OfflineFixturePath, [string] $LicenseKey, [string] $TenantId, [string] $ClientId, [string] $CertificateThumbprint, [datetime] $ReferenceDate = (Get-Date) ) $priceList = Get-OpteraSkuPriceList -PriceListPath $PriceListPath if ($OfflineFixturePath) { if ($Forest) { Write-Warning '-Forest has no effect in offline fixture mode (the fixture already represents the whole environment); ignoring it.' } Write-Verbose "Offline mode: loading fixtures from $OfflineFixturePath" $adAccounts = Get-Content -LiteralPath (Join-Path $OfflineFixturePath 'ad-accounts.sample.json') -Raw | ConvertFrom-Json $graphUsers = Get-Content -LiteralPath (Join-Path $OfflineFixturePath 'graph-users.sample.json') -Raw | ConvertFrom-Json $skusFixture = Join-Path $OfflineFixturePath 'graph-skus.sample.json' $subscribedSkus = if (Test-Path -LiteralPath $skusFixture) { @(Get-Content -LiteralPath $skusFixture -Raw | ConvertFrom-Json) } else { @() } $tenantLabel = 'offline-fixture' } else { $context = Connect-ReclaimGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint $tenantLabel = if ($context) { $context.TenantId } else { 'unknown' } if ($Forest) { if ($SearchBase) { Write-Warning '-SearchBase is ignored in -Forest mode; each discovered domain is scanned from its own naming-context root.' } Write-Verbose 'Collecting on-prem AD account signals across the whole forest...' $adAccounts = @(Get-AdForestAccountSignals -Server $Server) } else { Write-Verbose 'Collecting on-prem AD account signals...' $adAccounts = @(Get-AdAccountSignals -SearchBase $SearchBase -Server $Server) } Write-Verbose 'Collecting cloud licensed users from Microsoft Graph...' $graphUsers = @(Get-GraphLicensedUsers) # Tenant license inventory (prepaid vs consumed) powers the owned-vs-reclaimable view. It is # enrichment, not the core finding, so a failure here (e.g. Organization.Read.All not # consented) must not sink the scan - degrade to an empty inventory and carry on. Write-Verbose 'Collecting tenant license inventory (subscribedSkus) from Microsoft Graph...' $subscribedSkus = @() try { $subscribedSkus = @(Get-GraphSubscribedSkus) } catch { Write-Warning ("Could not read tenant license inventory (subscribedSkus): {0}. The owned-vs-reclaimable view will be omitted; ensure Organization.Read.All or Directory.Read.All is consented." -f $_.Exception.Message) } } $reclaimable = @(Join-AccountLicense -AdAccounts @($adAccounts) -GraphUsers @($graphUsers) -StaleDays $StaleDays -ReferenceDate $ReferenceDate -ExcludeOu $ExcludeOu) $measured = Measure-WastedSpend -Reclaimable $reclaimable -PriceList $priceList $inventory = @(Get-SkuReclaimInventory -BySku @($measured.Summary.BySku) -SubscribedSkus @($subscribedSkus)) # Offline fixture runs validate by format only (no tenant, no network); live runs validate the # key against the connected tenant via the Optera AI endpoint. if ($OfflineFixturePath) { $license = Test-OpteraLicenseKey -LicenseKey $LicenseKey -Offline } else { $license = Test-OpteraLicenseKey -LicenseKey $LicenseKey -TenantId $tenantLabel } [pscustomobject]@{ GeneratedAt = $ReferenceDate Mode = $license.Mode TenantId = $tenantLabel StaleDays = $StaleDays Summary = $measured.Summary Inventory = $inventory Reclaimable = $measured.PricedReclaimable License = $license Disclaimer = 'Figures use Microsoft list prices and may differ from your EA/CSP/negotiated rate. Verify before acting. Read-only scan; no directory contents leave your network.' } } |