src/Public/Invoke-OpteraLicenseScan.ps1
|
function Invoke-OpteraLicenseScan { <# .SYNOPSIS Scans for Microsoft 365 licenses still assigned to dead/stale on-prem AD accounts and reports the monthly dollars wasted. .DESCRIPTION Read-only. Correlates on-premises Active Directory account state with Microsoft Graph license assignments for a hybrid (Entra Connect synced) tenant, prices the reclaimable licenses, and writes a report. The scan always runs and computes locally; the Free/Full license gate only controls whether the per-account remediation detail is revealed. Connect to Graph first (interactive) or pass app-only credentials. Use -OfflineFixturePath to run the whole pipeline against sample data with no tenant - handy for a demo or testing. .EXAMPLE Invoke-OpteraLicenseScan Interactive Graph sign-in, scans the current domain, prints the headline to the console. .EXAMPLE Invoke-OpteraLicenseScan -StaleDays 120 -OutputPath C:\Reports -Format Both Writes both an HTML report and (if unlocked) the remediation CSV. .EXAMPLE Invoke-OpteraLicenseScan -Forest -Verbose Multi-domain forest: discovers every domain, scans each, and aggregates the totals. Verbose output lists the per-domain account tallies. .EXAMPLE Invoke-OpteraLicenseScan -OfflineFixturePath .\tests\fixtures -OutputPath .\out -Format Html No tenant required - renders a sample report from the bundled fixtures. .OUTPUTS The report object (also see -PassThru). Console summary is always written. #> [CmdletBinding()] param( [string] $SearchBase, [string] $Server, # Scan every domain in the forest, not just the current one. Use this for multi-domain # forests so licensed dead accounts in child/sibling domains are counted. Default # (single-domain) behaviour is unchanged. [switch] $Forest, [ValidateRange(1, 3650)] [int] $StaleDays = 90, # OU(s) to suppress from the candidate list - e.g. a shared-mailbox or litigation-hold OU whose # licensed-but-disabled accounts are intentional. Accepts a full OU DN or a single component. [string[]] $ExcludeOu, [string] $PriceListPath, [string] $OfflineFixturePath, # Where to write file reports; defaults to the current directory when a Format is chosen. [string] $OutputPath, [ValidateSet('None', 'Html', 'Csv', 'Both')] [string] $Format = 'None', [string] $LicenseKey, # App-only (client credentials) Graph auth - omit for interactive sign-in. [string] $TenantId, [string] $ClientId, [string] $CertificateThumbprint, # Reference 'now' for staleness math; exposed mainly for deterministic testing. [datetime] $ReferenceDate = (Get-Date), [switch] $PassThru ) # Call the core explicitly (do not splat $PSBoundParameters - it carries OutputPath/Format/PassThru # which the core does not accept). $report = Invoke-LicenseReclaimScanCore -SearchBase $SearchBase -Server $Server -Forest:$Forest -StaleDays $StaleDays ` -ExcludeOu $ExcludeOu -PriceListPath $PriceListPath -OfflineFixturePath $OfflineFixturePath -LicenseKey $LicenseKey ` -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint ` -ReferenceDate $ReferenceDate Write-ReclaimReportConsole -Report $report if ($Format -ne 'None') { $dir = if ($OutputPath) { $OutputPath } else { (Get-Location).Path } if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $stamp = '{0:yyyyMMdd-HHmmss}' -f $report.GeneratedAt if ($Format -in 'Html', 'Both') { $htmlPath = New-ReclaimReportHtml -Report $report -Path (Join-Path $dir "license-reclaim-$stamp.html") Write-Host " HTML report: $htmlPath" -ForegroundColor Cyan } if ($Format -in 'Csv', 'Both') { $csvPath = New-ReclaimReportCsv -Report $report -Path (Join-Path $dir "license-reclaim-$stamp.csv") if ($csvPath) { Write-Host " CSV remediation list: $csvPath" -ForegroundColor Cyan } } } # Console (and any files) are the default output; return the object only when asked, so the # rich report isn't followed by PowerShell format-listing the whole object to the screen. if ($PassThru) { return $report } } |