LicenseMeterScan.psm1
|
#requires -Version 7.0 # LicenseMeter Scan # Free, read-only Microsoft 365 license-waste scanner. Runs locally in your own tenant. # https://github.com/ugurkocde/licensemeter Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ---------------------------------------------------------------------------- # Constants # ---------------------------------------------------------------------------- $script:ModuleVersion = '1.5.1' $script:GraphBase = 'https://graph.microsoft.com/beta' $script:UserSelect = 'id,userPrincipalName,displayName,accountEnabled,userType,assignedLicenses,createdDateTime,signInActivity' $script:UserSelectNoSignIn = 'id,userPrincipalName,displayName,accountEnabled,userType,assignedLicenses,createdDateTime' # AuditLog.Read.All powers signInActivity (the preferred activity signal). Reports.Read.All powers the # usage-report fallback for tenants without Entra ID P1. Both are read-only and optional; the disabled # check runs without either. Organization.Read.All also covers subscribedSkus and the tenant name. $script:DefaultScopes = @('User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All', 'Reports.Read.All') $script:NeverSignedInGraceDays = 30 $script:RuleLabels = @{ DisabledLicensed = 'Disabled account, still licensed' InactiveLicensed = 'Inactive {0}+ days, still licensed' NeverSignedInLicensed = 'Never signed in, still licensed' RedundantLicensed = 'Redundant license, already in a suite' } $script:RuleOrder = @{ DisabledLicensed = 1 InactiveLicensed = 2 NeverSignedInLicensed = 3 RedundantLicensed = 4 } # R4 overlap map: standalone skuPartNumber -> the suite SKUs that already include it. Deliberately # conservative; only high-confidence, unambiguous inclusions so the tool never cries waste on a # license the user actually needs. A user holding both the standalone and any listed suite has the # standalone flagged as redundant (priced at the standalone's own price). # Keep every part number used here (standalone and suite) present in $script:SkuFriendlyName too, so # the report's "covered by" label shows a friendly suite name rather than the raw part number. $script:RedundantInSuite = @{ # Entra ID P1 is included in EMS, M365 E3/E5, and is a subset of Entra ID P2. AAD_PREMIUM = @('AAD_PREMIUM_P2', 'EMS', 'EMSPREMIUM', 'SPE_E3', 'SPE_E5') # Entra ID P2 is included in EMS E5 and M365 E5 (note: O365 E5 does NOT include Entra P2). AAD_PREMIUM_P2 = @('EMSPREMIUM', 'SPE_E5') # EMS E3 is included in M365 E3/E5; EMS E5 is included in M365 E5. EMS = @('SPE_E3', 'SPE_E5') EMSPREMIUM = @('SPE_E5') # Intune is included in EMS, M365 E3/E5, and the Intune Suite. INTUNE_A = @('EMS', 'EMSPREMIUM', 'SPE_E3', 'SPE_E5', 'Microsoft_Intune_Suite') # Exchange Online (Plan 1) ships in every O365/M365 suite that includes email. EXCHANGESTANDARD = @('STANDARDPACK', 'ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5', 'O365_BUSINESS_ESSENTIALS', 'O365_BUSINESS_PREMIUM', 'SPB') # Exchange Online (Plan 2) ships in the E3/E5 tiers. EXCHANGEENTERPRISE = @('ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5') # SharePoint (Plan 1) ships in every O365/M365 suite. SHAREPOINTSTANDARD = @('STANDARDPACK', 'ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5', 'O365_BUSINESS_ESSENTIALS', 'O365_BUSINESS_PREMIUM', 'SPB') # SharePoint (Plan 2) ships in the E3/E5 tiers. SHAREPOINTENTERPRISE = @('ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5') # Power BI Pro is included in O365/M365 E5. POWER_BI_PRO = @('ENTERPRISEPREMIUM', 'SPE_E5') } # Friendly display names for common SKUs. Falls back to the raw skuPartNumber. $script:SkuFriendlyName = @{ STANDARDPACK = 'Office 365 E1' ENTERPRISEPACK = 'Office 365 E3' ENTERPRISEPREMIUM = 'Office 365 E5' SPE_E3 = 'Microsoft 365 E3' SPE_E5 = 'Microsoft 365 E5' SPE_F1 = 'Microsoft 365 F1' SPE_F3 = 'Microsoft 365 F3' DESKLESSPACK = 'Office 365 F3' DEVELOPERPACK_E5 = 'Microsoft 365 E5 (Developer)' SPB = 'Microsoft 365 Business Premium' O365_BUSINESS_PREMIUM = 'Microsoft 365 Business Standard' O365_BUSINESS_ESSENTIALS = 'Microsoft 365 Business Basic' O365_BUSINESS = 'Microsoft 365 Apps for Business' OFFICESUBSCRIPTION = 'Microsoft 365 Apps for Enterprise' EXCHANGESTANDARD = 'Exchange Online (Plan 1)' EXCHANGEENTERPRISE = 'Exchange Online (Plan 2)' EXCHANGEDESKLESS = 'Exchange Online Kiosk' SHAREPOINTSTANDARD = 'SharePoint Online (Plan 1)' SHAREPOINTENTERPRISE = 'SharePoint Online (Plan 2)' WACONEDRIVESTANDARD = 'OneDrive for Business (Plan 1)' WACONEDRIVEENTERPRISE = 'OneDrive for Business (Plan 2)' POWER_BI_PRO = 'Power BI Pro' PBI_PREMIUM_PER_USER = 'Power BI Premium Per User' PROJECT_PLAN1 = 'Project Plan 1' PROJECTPROFESSIONAL = 'Project Plan 3' PROJECTPREMIUM = 'Project Plan 5' VISIOONLINE_PLAN1 = 'Visio Plan 1' VISIOCLIENT = 'Visio Plan 2' Microsoft_365_Copilot = 'Microsoft 365 Copilot' AAD_PREMIUM = 'Microsoft Entra ID P1' AAD_PREMIUM_P2 = 'Microsoft Entra ID P2' EMS = 'Enterprise Mobility + Security E3' EMSPREMIUM = 'Enterprise Mobility + Security E5' INTUNE_A = 'Microsoft Intune Plan 1' Microsoft_Intune_Suite = 'Microsoft Intune Suite' DEFENDER_ENDPOINT_P1 = 'Defender for Endpoint P1' WIN_DEF_ATP = 'Defender for Endpoint P2' ATP_ENTERPRISE = 'Defender for Office 365 (Plan 1)' THREAT_INTELLIGENCE = 'Defender for Office 365 (Plan 2)' IDENTITY_THREAT_PROTECTION = 'Microsoft 365 E5 Security' INFORMATION_PROTECTION_COMPLIANCE = 'Microsoft 365 E5 Compliance' RIGHTSMANAGEMENT = 'Azure Information Protection P1' WIN10_VDA_E3 = 'Windows 10/11 Enterprise E3' WIN10_VDA_E5 = 'Windows 10/11 Enterprise E5' MCOEV = 'Microsoft Teams Phone Standard' MCOMEETADV = 'Microsoft 365 Audio Conferencing' MCOPSTN1 = 'Skype for Business PSTN Domestic Calling' TEAMS_PREMIUM = 'Microsoft Teams Premium' POWERAPPS_PER_USER = 'Power Apps per user plan' FLOW_PER_USER = 'Power Automate per user plan' FLOW_FREE = 'Microsoft Power Automate Free' POWER_BI_STANDARD = 'Power BI (free)' TEAMS_EXPLORATORY = 'Microsoft Teams Exploratory' TEAMS_FREE = 'Microsoft Teams (Free)' POWERAPPS_VIRAL = 'Power Apps Plan 2 Trial' } # ---------------------------------------------------------------------------- # Helpers (pure) # ---------------------------------------------------------------------------- function Get-LMProp { # Read a property from either a Hashtable or a PSObject, returning $null when absent. # Keeps the rule engine robust to PSObject (live Graph / fixtures) and Hashtable shapes. param($Object, [string]$Name) if ($null -eq $Object) { return $null } if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Name)) { return $Object[$Name] } return $null } $prop = $Object.PSObject.Properties[$Name] if ($prop) { return $prop.Value } return $null } function Format-LMMoney { param([long]$Cents, [string]$Currency = 'EUR') $value = [decimal]$Cents / 100 '{0} {1}' -f $Currency, $value.ToString('N2', [System.Globalization.CultureInfo]::InvariantCulture) } function ConvertTo-LMDateOffset { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } try { return [datetimeoffset]::Parse( $Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) } catch { return $null } } function Get-LMLastActivity { # Most recent of the available signInActivity timestamps, or $null when none. param($SignInActivity) if ($null -eq $SignInActivity) { return $null } $raw = @( (Get-LMProp $SignInActivity 'lastSignInDateTime') (Get-LMProp $SignInActivity 'lastNonInteractiveSignInDateTime') (Get-LMProp $SignInActivity 'lastSuccessfulSignInDateTime') ) $dates = foreach ($r in $raw) { $d = ConvertTo-LMDateOffset $r; if ($d) { $d } } if (-not $dates) { return $null } return ($dates | Sort-Object | Select-Object -Last 1) } function Get-LMSkuFriendlyName { param([string]$PartNumber) if ($PartNumber -and $script:SkuFriendlyName.ContainsKey($PartNumber)) { return $script:SkuFriendlyName[$PartNumber] } if ($PartNumber) { return $PartNumber } return '(unknown)' } function Get-LMCentTotal { # Integer accumulation in cents. Avoids Measure-Object -Sum, which returns a [double]. param($Items) $sum = [long]0 foreach ($item in @($Items)) { if ($null -ne $item) { $sum += [long]$item.MonthlyCents } } return $sum } # ---------------------------------------------------------------------------- # Pricing (pure) # ---------------------------------------------------------------------------- function Get-LMPriceBook { # Load a price-book JSON file and normalise prices to integer cents. param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "Price file not found: $Path" } $json = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json $currency = if ($json.PSObject.Properties['currency']) { [string]$json.currency } else { 'EUR' } $period = if ($json.PSObject.Properties['period']) { [string]$json.period } else { 'monthly' } $prices = @{} if ($json.PSObject.Properties['prices'] -and $json.prices) { foreach ($entry in $json.prices.PSObject.Properties) { $cents = [long][math]::Round(([decimal]$entry.Value) * 100, [System.MidpointRounding]::AwayFromZero) $prices[$entry.Name] = $cents } } [pscustomobject]@{ Currency = $currency Period = $period Prices = $prices Path = $Path } } function Resolve-LMPriceMap { # Build skuId -> { PartNumber; Cents; Priced } from subscribedSkus and a price book. param( [Parameter(Mandatory)]$SubscribedSkus, [Parameter(Mandatory)]$PriceBook ) $map = @{} foreach ($sku in @($SubscribedSkus)) { $skuId = Get-LMProp $sku 'skuId' if (-not $skuId) { continue } $part = Get-LMProp $sku 'skuPartNumber' $cents = [long]0 $known = $false if ($part -and $PriceBook.Prices.ContainsKey($part)) { $known = $true $cents = [long]$PriceBook.Prices[$part] } $map[$skuId] = [pscustomobject]@{ SkuId = $skuId PartNumber = $part Cents = $cents Known = $known Priced = ($cents -gt 0) } } return $map } function Get-LMInventory { # Per-SKU license inventory for the report: assigned (consumed) vs available (prepaid), # plus the catalog price and whether it is known. Covers every subscribed SKU. param( [Parameter(Mandatory)]$SubscribedSkus, [Parameter(Mandatory)]$PriceMap ) $list = [System.Collections.Generic.List[object]]::new() foreach ($sku in @($SubscribedSkus)) { $skuId = Get-LMProp $sku 'skuId' if (-not $skuId) { continue } $part = Get-LMProp $sku 'skuPartNumber' $prepaid = Get-LMProp $sku 'prepaidUnits' $enabled = if ($prepaid) { Get-LMProp $prepaid 'enabled' } else { $null } $entry = if ($PriceMap.ContainsKey($skuId)) { $PriceMap[$skuId] } else { $null } $list.Add([pscustomobject]@{ SkuId = $skuId Part = [string]$part Name = Get-LMSkuFriendlyName $part Assigned = [int](Get-LMProp $sku 'consumedUnits') Available = if ($null -ne $enabled) { [int]$enabled } else { 0 } Cents = if ($entry) { [long]$entry.Cents } else { [long]0 } Known = if ($entry) { [bool]$entry.Known } else { $false } }) } return $list.ToArray() } # ---------------------------------------------------------------------------- # Rule engine (pure, no I/O) # ---------------------------------------------------------------------------- function Invoke-LMRule { <# .SYNOPSIS Apply the waste rules (R1-R3) to already-fetched users and return findings. .DESCRIPTION Pure function. Takes plain objects plus an explicit -AsOf so results are deterministic and unit-testable. One finding per (user, paid SKU). Each user matches at most one rule (R1 disabled takes precedence). #> param( [Parameter(Mandatory)]$Users, [Parameter(Mandatory)][hashtable]$PriceMap, [Parameter(Mandatory)][datetimeoffset]$AsOf, [int]$InactiveDays = 90, [bool]$HasSignInData = $true, [int]$GraceDays = 30, [hashtable]$ActivityByUpn = $null ) $findings = [System.Collections.Generic.List[object]]::new() foreach ($user in @($Users)) { $licenses = @(Get-LMProp $user 'assignedLicenses') if ($licenses.Count -eq 0) { continue } $billable = [System.Collections.Generic.List[object]]::new() foreach ($lic in $licenses) { $skuId = Get-LMProp $lic 'skuId' if ($skuId -and $PriceMap.ContainsKey($skuId)) { $entry = $PriceMap[$skuId] # Flag priced AND not-yet-priced (unknown) SKUs so the report can price them; skip known-free SKUs. if (-not $entry.Known -or $entry.Cents -gt 0) { $billable.Add($entry) } } } if ($billable.Count -eq 0) { continue } $enabled = [bool](Get-LMProp $user 'accountEnabled') $userType = [string](Get-LMProp $user 'userType') $rule = $null $lastActivity = $null $daysInactive = $null if (-not $enabled) { # R1: disabled account still holding a paid license. Needs no activity data. $rule = 'DisabledLicensed' } else { # Resolve this user's activity. Two sources, mutually exclusive: # - $ActivityByUpn (usage-report fallback): authoritative PER USER. A user absent from the # map has no signal (the report only lists users with a covered workload), so R2/R3 are # skipped for them to avoid false-flagging e.g. an Entra-only account as never signed in. # - signInActivity on the user object (preferred path) when $HasSignInData. $activityAvailable = $false if ($ActivityByUpn) { $upn = [string](Get-LMProp $user 'userPrincipalName') $key = if ($upn) { $upn.ToLowerInvariant() } else { $null } if ($key -and $ActivityByUpn.ContainsKey($key)) { $activityAvailable = $true $raw = $ActivityByUpn[$key] $lastActivity = if ($raw) { ConvertTo-LMDateOffset $raw } else { $null } } } elseif ($HasSignInData) { $activityAvailable = $true $lastActivity = Get-LMLastActivity (Get-LMProp $user 'signInActivity') } if ($activityAvailable) { if ($null -eq $lastActivity) { # R3: never signed in, but only past a grace period so brand-new accounts are spared. $created = ConvertTo-LMDateOffset (Get-LMProp $user 'createdDateTime') if ($created -and (($AsOf - $created).TotalDays -gt $GraceDays)) { $rule = 'NeverSignedInLicensed' } } else { # R2: licensed but inactive for at least InactiveDays. $daysInactive = [int][math]::Floor(($AsOf - $lastActivity).TotalDays) if ($daysInactive -ge $InactiveDays) { $rule = 'InactiveLicensed' } } } # No activity signal for this account => R2/R3 cannot be evaluated; the user is skipped # and the report says so. } if ($null -eq $rule) { continue } foreach ($p in $billable) { $findings.Add([pscustomobject]@{ Rule = $rule UserId = Get-LMProp $user 'id' UserPrincipalName = Get-LMProp $user 'userPrincipalName' DisplayName = Get-LMProp $user 'displayName' AccountEnabled = $enabled UserType = $userType SkuId = $p.SkuId SkuPartNumber = $p.PartNumber SkuName = Get-LMSkuFriendlyName $p.PartNumber MonthlyCents = [long]$p.Cents PriceKnown = $p.Known LastActivity = if ($lastActivity) { $lastActivity.UtcDateTime.ToString('yyyy-MM-dd') } else { $null } DaysInactive = $daysInactive CoveredBy = $null }) } } return $findings.ToArray() } function Get-LMRedundantFinding { <# .SYNOPSIS R4: flag standalone licenses already covered by a suite the same user holds. Pure. .DESCRIPTION Runs only against users NOT already flagged by R1-R3 (passed via -ExcludeUserIds), so a disabled or inactive account's licenses are never double-counted. One finding per redundant standalone license, priced at the standalone's own cost. Uses the conservative $script:RedundantInSuite map so it never flags a license the user genuinely needs. #> param( [Parameter(Mandatory)]$Users, [Parameter(Mandatory)][hashtable]$PriceMap, [string[]]$ExcludeUserIds = @() ) $exclude = @{} foreach ($id in @($ExcludeUserIds)) { if ($id) { $exclude[$id] = $true } } $findings = [System.Collections.Generic.List[object]]::new() foreach ($user in @($Users)) { $uid = Get-LMProp $user 'id' if ($uid -and $exclude.ContainsKey($uid)) { continue } $licenses = @(Get-LMProp $user 'assignedLicenses') if ($licenses.Count -lt 2) { continue } # Resolve held SKUs to part numbers via the price map (skuId -> PartNumber). $heldParts = @{} $partToEntry = @{} foreach ($lic in $licenses) { $skuId = Get-LMProp $lic 'skuId' if ($skuId -and $PriceMap.ContainsKey($skuId)) { $entry = $PriceMap[$skuId] if ($entry.PartNumber) { $heldParts[$entry.PartNumber] = $true; $partToEntry[$entry.PartNumber] = $entry } } } if ($heldParts.Count -lt 2) { continue } foreach ($standalone in $script:RedundantInSuite.Keys) { if (-not $heldParts.ContainsKey($standalone)) { continue } $covering = $null foreach ($s in $script:RedundantInSuite[$standalone]) { if ($heldParts.ContainsKey($s)) { $covering = $s; break } } if (-not $covering) { continue } $entry = $partToEntry[$standalone] # Mirror R1-R3: surface priced and not-yet-priced SKUs (so the report can price them); skip known-free. if ($entry.Known -and $entry.Cents -le 0) { continue } $findings.Add([pscustomobject]@{ Rule = 'RedundantLicensed' UserId = $uid UserPrincipalName = Get-LMProp $user 'userPrincipalName' DisplayName = Get-LMProp $user 'displayName' AccountEnabled = [bool](Get-LMProp $user 'accountEnabled') UserType = [string](Get-LMProp $user 'userType') SkuId = $entry.SkuId SkuPartNumber = $entry.PartNumber SkuName = Get-LMSkuFriendlyName $entry.PartNumber MonthlyCents = [long]$entry.Cents PriceKnown = $entry.Known LastActivity = $null DaysInactive = $null CoveredBy = Get-LMSkuFriendlyName $covering }) } } return $findings.ToArray() } function Get-LMScanSummary { # Aggregate findings into the report summary. Pure. param( $Findings, [int]$UsersScanned, [string]$Currency = 'EUR', [bool]$HasSignInData = $true, [int]$InactiveDays = 90, [string]$ActivitySource = 'signInActivity' ) $all = @($Findings | Where-Object { $null -ne $_ }) $totalCents = Get-LMCentTotal $all $byRule = foreach ($group in ($all | Group-Object Rule)) { $label = $script:RuleLabels[$group.Name] -f $InactiveDays [pscustomobject]@{ Rule = $group.Name Label = $label Users = (@($group.Group | Select-Object -ExpandProperty UserId -Unique)).Count Findings = $group.Count MonthlyCents = Get-LMCentTotal $group.Group } } $bySku = foreach ($group in ($all | Group-Object SkuPartNumber)) { [pscustomobject]@{ SkuPartNumber = $group.Name SkuName = Get-LMSkuFriendlyName $group.Name Count = $group.Count MonthlyCents = Get-LMCentTotal $group.Group } } $guestFindings = @($all | Where-Object { [string](Get-LMProp $_ 'UserType') -eq 'Guest' }) [pscustomobject]@{ Currency = $Currency UsersScanned = $UsersScanned HasSignInData = $HasSignInData ActivitySource = $ActivitySource InactiveDays = $InactiveDays TotalMonthlyCents = $totalCents TotalAnnualCents = $totalCents * 12 FindingCount = $all.Count GuestFindingCount = $guestFindings.Count ByRule = @($byRule | Sort-Object { $script:RuleOrder[$_.Rule] }) BySku = @($bySku | Sort-Object -Property MonthlyCents -Descending) } } # ---------------------------------------------------------------------------- # Scope guard (pure) # ---------------------------------------------------------------------------- function Assert-LMReadOnlyScope { # Refuse to run with any write-capable scope. LicenseMeter Scan only ever reads. param([string[]]$Scopes) $bad = foreach ($s in @($Scopes)) { if ($s -match '(?i)(write|\.send|\.manage|manage\.|delete|owns|fullcontrol)') { $s } } if ($bad) { throw "LicenseMeter Scan is read-only and refuses write scope(s): $($bad -join ', ')" } } function Assert-LMGraphContextReadOnly { # Reused Graph sessions must satisfy the same read-only invariant as sessions we create. param($Context) $granted = Get-LMProp $Context 'Scopes' if ($granted) { Assert-LMReadOnlyScope -Scopes @($granted) } } # ---------------------------------------------------------------------------- # Microsoft Graph I/O (via MgGraphCommunity) # ---------------------------------------------------------------------------- function Connect-LMGraph { <# .SYNOPSIS Sign in to Microsoft Graph with read-only scopes via MgGraphCommunity. .DESCRIPTION Wraps Connect-MgGraphCommunity. Interactive browser sign-in by default; -UseDeviceCode for headless sessions. No app registration is required. #> [CmdletBinding()] param( [string[]]$Scopes = $script:DefaultScopes, [string]$TenantId, [string]$ClientId, [switch]$UseDeviceCode ) if (-not (Get-Command Connect-MgGraphCommunity -ErrorAction SilentlyContinue)) { throw "MgGraphCommunity is not installed. Run: Install-Module MgGraphCommunity -Scope CurrentUser" } Assert-LMReadOnlyScope -Scopes $Scopes $params = @{ Scopes = $Scopes; NoWelcome = $true; ErrorAction = 'Stop' } if ($UseDeviceCode) { $params['UseDeviceCode'] = $true } if ($TenantId) { $params['TenantId'] = $TenantId } if ($ClientId) { $params['ClientId'] = $ClientId } Connect-MgGraphCommunity @params | Out-Null $ctx = Get-MgGraphCommunityContext Assert-LMGraphContextReadOnly -Context $ctx return $ctx } function Test-LMConnected { $ctx = $null try { $ctx = Get-MgGraphCommunityContext } catch { return $false } return [bool]$ctx } function Invoke-LMGraphWithRetry { # Run a Graph request with bounded exponential backoff on transient failures. MgGraphCommunity # already retries 401/429/504 once internally; this adds an outer guard so a large-tenant scan # survives sustained throttling (429/503/504/timeouts) instead of aborting on a single burst. param([Parameter(Mandatory)][scriptblock]$Action, [int]$MaxAttempts = 5) $attempt = 0 while ($true) { $attempt++ try { return & $Action } catch { $msg = "$($_.Exception.Message)" $transient = $msg -match '(?i)(\b429\b|\b503\b|\b504\b|throttl|too many requests|timed out|temporarily|service unavailable|gateway)' if (-not $transient -or $attempt -ge $MaxAttempts) { throw } $delay = [int][math]::Min(60, [math]::Pow(2, $attempt)) Write-Verbose "Transient Graph error (attempt $attempt/$MaxAttempts): $msg. Backing off $delay s." Start-Sleep -Seconds $delay } } } function Invoke-LMGraphList { # GET a Graph collection, following @odata.nextLink with host validation, with progress and retry. param([Parameter(Mandatory)][string]$Uri, [string]$Activity = 'Reading from Microsoft Graph') $results = [System.Collections.Generic.List[object]]::new() $next = $Uri $page = 0 try { while ($next) { # Validate every request URL (the initial one and each @odata.nextLink) is an # absolute Graph URL. Rejects off-host follow links and relative-link bypasses. if ($next -notmatch '^https://graph\.microsoft\.com/') { throw "Refusing to request a non-Graph URL: $next" } $page++ Write-Progress -Id 1 -Activity $Activity -Status ("Page {0} ({1} records so far)" -f $page, $results.Count) $response = Invoke-LMGraphWithRetry { Invoke-MgGraphCommunityRequest -Method GET -Uri $next -OutputType PSObject } $value = Get-LMProp $response 'value' if ($value) { foreach ($item in @($value)) { $results.Add($item) } } $next = Get-LMProp $response '@odata.nextLink' } } finally { Write-Progress -Id 1 -Activity $Activity -Completed } return $results.ToArray() } function Test-LMSignInActivityUnavailableError { # Only degrade when the signInActivity select itself is rejected for permission/license reasons. param([string]$Reason) return ($Reason -match '(?i)(signInActivity|AuditLog\.Read\.All|premium|Entra ID P1|tenant.*licen|authorization_requestdenied|forbidden|\b403\b|insufficient privileges)') } function Get-LMUsagePeriod { # Smallest supported usage-report window (D7/D30/D90/D180) that covers the inactivity threshold. param([int]$InactiveDays = 90) foreach ($d in 7, 30, 90, 180) { if ($InactiveDays -le $d) { return "D$d" } } return 'D180' } function ConvertFrom-LMUsageReportCsv { # Parse the getOffice365ActiveUserDetail CSV into UPN(lower) -> last-activity 'yyyy-MM-dd' (or $null # when the user appears with no activity in the window). Detects concealed-name tenants, where the # UPN column holds opaque tokens instead of addresses, making the per-user join impossible. param([Parameter(Mandatory)][string]$Csv) # The Graph usage report is served as UTF-8 with a byte-order mark; strip a leading BOM so the # first CSV header parses cleanly (verified against a live tenant: the body starts with EF BB BF). $Csv = $Csv.TrimStart([char]0xFEFF) $rows = @($Csv | ConvertFrom-Csv) $activityCols = @( 'Exchange Last Activity Date', 'OneDrive Last Activity Date', 'SharePoint Last Activity Date', 'Skype For Business Last Activity Date', 'Yammer Last Activity Date', 'Teams Last Activity Date') $map = @{} $total = 0 $emailish = 0 foreach ($row in $rows) { $upn = [string](Get-LMProp $row 'User Principal Name') if ([string]::IsNullOrWhiteSpace($upn)) { continue } $total++ if ($upn -match '@') { $emailish++ } $newest = $null foreach ($col in $activityCols) { $d = ConvertTo-LMDateOffset ([string](Get-LMProp $row $col)) if ($d -and (-not $newest -or $d -gt $newest)) { $newest = $d } } $map[$upn.ToLowerInvariant()] = if ($newest) { $newest.UtcDateTime.ToString('yyyy-MM-dd') } else { $null } } # Concealed-name reports replace UPNs with opaque IDs; if few values look like addresses, give up # rather than mis-join. Admins can turn this off in the M365 admin center (we never change it). $concealed = ($total -gt 0 -and ($emailish / [double]$total) -lt 0.5) [pscustomobject]@{ Map = $map; Concealed = $concealed; Available = ($total -gt 0 -and -not $concealed) } } function Get-LMUsageActivityMap { # Fallback activity signal for tenants without Entra ID P1: the Microsoft 365 active-user usage # report (Reports.Read.All). Returns the parsed map plus availability/concealment flags. Never # throws: any failure (no scope, admin role, concealment, unexpected shape) degrades to unavailable. param([int]$InactiveDays = 90) $period = Get-LMUsagePeriod -InactiveDays $InactiveDays $uri = "$script:GraphBase/reports/getOffice365ActiveUserDetail(period='$period')" try { Write-Progress -Id 1 -Activity 'Reading from Microsoft Graph' -Status 'Usage report (sign-in fallback)' # PSObject output returns the raw body for non-JSON responses; the report is CSV. MgGraphCommunity # follows the 302 to the preauthenticated download URL internally (PS7 strips auth cross-origin). $resp = Invoke-LMGraphWithRetry { Invoke-MgGraphCommunityRequest -Method GET -Uri $uri -OutputType PSObject } $csv = if ($resp -is [string]) { $resp } else { [string](Get-LMProp $resp 'Content') } if ([string]::IsNullOrWhiteSpace($csv) -or $csv -notmatch 'User Principal Name') { $shape = if ($null -ne $resp) { $resp.GetType().FullName } else { 'null' } Write-Verbose "Usage report returned no usable CSV (response shape: $shape); inactivity checks stay disabled." return [pscustomobject]@{ Map = @{}; Concealed = $false; Available = $false } } return ConvertFrom-LMUsageReportCsv -Csv $csv } catch { Write-Verbose "Usage-report fallback unavailable: $($_.Exception.Message)" return [pscustomobject]@{ Map = @{}; Concealed = $false; Available = $false } } } function Get-LMUser { # Fetch licensed-relevant user fields. Prefers signInActivity (Entra ID P1 + AuditLog.Read.All). # If that select is rejected, retries without it and tries the usage-report fallback so inactivity # checks still work on tenants without P1. Reports which activity signal (if any) was used. [CmdletBinding()] param([int]$InactiveDays = 90) $uriFull = "$script:GraphBase/users?`$select=$script:UserSelect&`$top=999" try { $users = Invoke-LMGraphList -Uri $uriFull -Activity 'Reading users' return [pscustomobject]@{ Users = $users; HasSignInData = $true; ActivitySource = 'signInActivity'; ActivityByUpn = $null } } catch { # Degrade gracefully only for the "no Entra P1 / no AuditLog.Read.All" case, where # selecting signInActivity is rejected. Re-throw anything else (throttling, 5xx, # network, or our own non-Graph-URL guard) so real failures are never hidden. $reason = "$($_.Exception.Message)" if (-not (Test-LMSignInActivityUnavailableError -Reason $reason)) { throw } Write-Verbose "signInActivity unavailable (permission or license): $reason. Retrying without it." $uriNoSignIn = "$script:GraphBase/users?`$select=$script:UserSelectNoSignIn&`$top=999" $users = Invoke-LMGraphList -Uri $uriNoSignIn -Activity 'Reading users' # Try the usage-report fallback so inactive / never-signed-in checks still run without P1. $usage = Get-LMUsageActivityMap -InactiveDays $InactiveDays if ($usage.Available) { return [pscustomobject]@{ Users = $users; HasSignInData = $true; ActivitySource = 'usageReport'; ActivityByUpn = $usage.Map } } $src = if ($usage.Concealed) { 'concealed' } else { 'none' } return [pscustomobject]@{ Users = $users; HasSignInData = $false; ActivitySource = $src; ActivityByUpn = $null } } } function Get-LMSubscribedSku { return Invoke-LMGraphList -Uri "$script:GraphBase/subscribedSkus" -Activity 'Reading subscribed SKUs' } # ---------------------------------------------------------------------------- # Reporting # ---------------------------------------------------------------------------- function Get-LMActivitySourceLabel { # Human-readable activity-signal source for the report header. param([string]$ActivitySource) switch ($ActivitySource) { 'signInActivity' { 'sign-in logs (Entra ID P1)' } 'usageReport' { 'Microsoft 365 usage reports' } 'concealed' { 'unavailable (usage report names are concealed)' } default { 'unavailable (no Entra ID P1)' } } } function Format-LMConsoleReport { param($Summary, [string]$TenantLabel = 'your tenant', [string]$ReportPath) $cur = $Summary.Currency $srcLabel = Get-LMActivitySourceLabel ([string]$Summary.ActivitySource) Write-Host '' Write-Host 'LicenseMeter Scan' -ForegroundColor White -NoNewline Write-Host " v$script:ModuleVersion" -ForegroundColor DarkGray Write-Host ("Tenant: {0} Users scanned: {1} Activity: {2}" -f $TenantLabel, $Summary.UsersScanned, $srcLabel) -ForegroundColor DarkGray Write-Host '' Write-Host ("Identified waste: {0} / month ({1} / year)" -f (Format-LMMoney $Summary.TotalMonthlyCents $cur), (Format-LMMoney $Summary.TotalAnnualCents $cur)) -ForegroundColor Yellow Write-Host ("Across {0} findings in {1} categories." -f $Summary.FindingCount, $Summary.ByRule.Count) Write-Host '' foreach ($r in $Summary.ByRule) { Write-Host (" {0,-42}{1,4} users {2}" -f $r.Label, $r.Users, (Format-LMMoney $r.MonthlyCents $cur)) } if ($Summary.BySku.Count -gt 0) { Write-Host '' Write-Host 'Top wasted licenses:' foreach ($s in (@($Summary.BySku) | Select-Object -First 5)) { Write-Host (" {0,-30}{1,3} {2}" -f $s.SkuName, $s.Count, (Format-LMMoney $s.MonthlyCents $cur)) } } if (-not $Summary.HasSignInData) { Write-Host '' $why = if ([string]$Summary.ActivitySource -eq 'concealed') { 'the usage report has concealed user names' } else { 'this tenant lacks Entra ID P1 / AuditLog.Read.All and the usage-report fallback was unavailable' } Write-Host ("Note: inactive and never-signed-in checks were skipped ({0}). The disabled and redundant checks still ran." -f $why) -ForegroundColor DarkYellow } if ($Summary.GuestFindingCount -gt 0) { Write-Host '' Write-Host ("Note: {0} finding(s) are on guest (external) accounts." -f $Summary.GuestFindingCount) -ForegroundColor DarkYellow } Write-Host '' Write-Host 'Figures use list prices. Edit prices.json for your contract.' -ForegroundColor DarkGray $tail = 'Read-only scan. Nothing left your tenant.' if ($ReportPath) { $tail += " Report: $ReportPath" } Write-Host $tail -ForegroundColor DarkGray Write-Host '' } function Protect-LMCsvCell { # Neutralise spreadsheet formula injection for tenant-controlled text fields: a leading # =, +, -, @, tab, or CR makes Excel/Sheets evaluate the cell as a formula on open. param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return $Value } if ($Value -match '^[=+\-@\t\r]') { return "'" + $Value } return $Value } function ConvertTo-LMFindingRow { # Flatten findings into CSV export rows. param($Findings, [string]$Currency = 'EUR') foreach ($f in @($Findings)) { [pscustomobject]@{ Rule = $f.Rule DisplayName = Protect-LMCsvCell $f.DisplayName UserPrincipalName = Protect-LMCsvCell $f.UserPrincipalName AccountEnabled = $f.AccountEnabled UserType = Protect-LMCsvCell ([string]$f.UserType) SkuPartNumber = Protect-LMCsvCell $f.SkuPartNumber SkuName = Protect-LMCsvCell $f.SkuName MonthlyCost = ([decimal]$f.MonthlyCents / 100).ToString('0.00', [System.Globalization.CultureInfo]::InvariantCulture) Currency = $Currency LastActivity = $f.LastActivity DaysInactive = $f.DaysInactive CoveredBy = Protect-LMCsvCell ([string]$f.CoveredBy) } } } function ConvertTo-LMHtmlEncoded { param([string]$Text) if ($null -eq $Text) { return '' } return [System.Net.WebUtility]::HtmlEncode($Text) } function Get-LMHtmlReport { # Interactive, self-contained HTML report: a full license inventory (assigned vs available) # plus the waste findings. Prices are editable in the browser and every total recalculates # live (these edits are for exploration; use -PriceFile for permanent prices). param($Summary, $Findings, $Inventory, [string]$TenantLabel = 'your tenant') $cur = if ($Summary.Currency) { [string]$Summary.Currency } else { 'EUR' } $generated = ([datetimeoffset]::UtcNow).ToString('yyyy-MM-dd HH:mm') + ' UTC' $shortLabel = @{ DisabledLicensed = 'Disabled' InactiveLicensed = 'Inactive' NeverSignedInLicensed = 'Never signed in' RedundantLicensed = 'Redundant' } $fData = foreach ($f in @($Findings)) { # The "detail" column shows the last activity for R1-R3, and the covering suite for R4. $detail = if ([string]$f.Rule -eq 'RedundantLicensed') { if ($f.CoveredBy) { 'in ' + [string]$f.CoveredBy } else { 'in a suite' } } elseif ($f.LastActivity) { [string]$f.LastActivity } else { 'never' } [pscustomobject]@{ name = [string]$f.DisplayName upn = [string]$f.UserPrincipalName rule = [string]$f.Rule label = $(if ($shortLabel.ContainsKey([string]$f.Rule)) { $shortLabel[[string]$f.Rule] } else { [string]$f.Rule }) last = $detail guest = ([string]$f.UserType -eq 'Guest') sku = [string]$f.SkuPartNumber skuName = [string]$f.SkuName } } $invData = foreach ($it in @($Inventory)) { if (-not $it) { continue } [pscustomobject]@{ name = [string]$it.Name part = [string]$it.Part assigned = [int]$it.Assigned available = [int]$it.Available amount = [math]::Round(([decimal]$it.Cents / 100), 2) known = [bool]$it.Known } } $data = [pscustomobject]@{ currency = $cur tenant = [string]$TenantLabel usersScanned = [int]$Summary.UsersScanned p1 = [bool]$Summary.HasSignInData activitySource = Get-LMActivitySourceLabel ([string]$Summary.ActivitySource) inactiveDays = [int]$Summary.InactiveDays generated = $generated ruleLabels = [ordered]@{ DisabledLicensed = 'Disabled account, still licensed' InactiveLicensed = "Inactive $([int]$Summary.InactiveDays)+ days, still licensed" NeverSignedInLicensed = 'Never signed in, still licensed' RedundantLicensed = 'Redundant license, already in a suite' } findings = @($fData) inventory = @($invData) } $dataJson = ($data | ConvertTo-Json -Depth 6 -Compress) # Neutralise <, >, & so tenant-controlled strings cannot break out of the <script> element. $dataJson = $dataJson.Replace('<', '\u003c').Replace('>', '\u003e').Replace('&', '\u0026') $template = @' <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>LicenseMeter Scan report</title> <style> :root { --paper:#faf8f3; --ink:#1c1a16; --rust:#bc3e12; --muted:#6f6a5f; --line:#e7e2d8; --card:#fffdf9; } * { box-sizing: border-box; } body { margin:0; background:var(--paper); color:var(--ink); font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; } .serif { font-family:'Iowan Old Style','Palatino Linotype',Palatino,'Hoefler Text',Georgia,ui-serif,serif; } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.78rem; color:var(--muted); } .dim { color:var(--muted); } .wrap { max-width:860px; margin:0 auto; padding:52px 44px 60px; } .topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:8px; } .brand { display:flex; align-items:center; gap:11px; } .wordmark { font-size:1.45rem; font-weight:600; letter-spacing:-0.01em; } .pill { border:1px solid var(--rust); color:var(--rust); border-radius:999px; font-size:.67rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; padding:5px 11px; white-space:nowrap; } .meta { color:var(--muted); font-size:.82rem; margin:0 0 28px; } .hero { border:1px solid var(--line); background:var(--card); border-radius:16px; padding:28px 30px; margin-bottom:13px; } .hero-label { text-transform:uppercase; letter-spacing:.09em; font-size:.71rem; font-weight:700; color:var(--muted); margin-bottom:12px; } .hero-num { font-size:3.5rem; line-height:.95; font-weight:700; color:var(--rust); font-variant-numeric:tabular-nums lining-nums; } .hero-num .per { font-size:1.25rem; color:var(--muted); font-weight:600; font-family:-apple-system,'Segoe UI',sans-serif; } .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; } .card { border:1px solid var(--line); background:var(--card); border-radius:13px; padding:17px 19px; } .card-num { font-size:1.65rem; font-weight:700; line-height:1.1; font-variant-numeric:tabular-nums lining-nums; } .card-lbl { color:var(--muted); font-size:.77rem; margin-top:3px; } h2 { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:700; margin:32px 0 9px; } .prices-hd { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:34px; } .prices-hd h2 { margin:0; } .hint { text-transform:none; letter-spacing:0; font-weight:400; color:var(--muted); font-size:.74rem; margin-left:10px; } button { font:inherit; font-size:.78rem; font-weight:600; color:var(--rust); background:var(--paper); border:1px solid var(--rust); border-radius:8px; padding:6px 13px; cursor:pointer; white-space:nowrap; } button:hover { background:var(--rust); color:var(--paper); } .controls { display:flex; align-items:center; gap:10px; } .curlbl { font-size:.76rem; color:var(--muted); display:flex; align-items:center; gap:6px; white-space:nowrap; } select { font:inherit; font-size:.8rem; color:var(--ink); background:#fff; border:1px solid var(--line); border-radius:8px; padding:5px 8px; cursor:pointer; } select:focus { outline:none; border-color:var(--rust); } table { border-collapse:collapse; width:100%; font-size:.85rem; } thead th { text-align:left; font-size:.66rem; text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; color:var(--muted); font-weight:700; padding:0 10px 8px; border-bottom:2px solid var(--ink); } tbody td { padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:middle; } td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; } input[type=number] { width:7rem; text-align:right; font:inherit; font-variant-numeric:tabular-nums; border:1px solid var(--line); border-radius:6px; padding:4px 7px; background:#fff; } input[type=number]:focus { outline:none; border-color:var(--rust); } .needs { color:var(--rust); font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; margin-left:8px; } .badge { display:inline-block; margin-left:6px; padding:1px 6px; border-radius:999px; font-size:.6rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border:1px solid var(--line); vertical-align:middle; } .clear { font-size:1.05rem; margin-top:24px; color:var(--ink); } .foot { margin-top:38px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:.78rem; line-height:1.5; } .foot a { color:var(--rust); text-decoration:none; } @media print { body { background:#fff; } .wrap { max-width:none; padding:0 12px; } .pill, button, .controls { display:none !important; } .hero, .card, table { break-inside:avoid; } input[type=number] { border:none; padding:0; background:transparent; width:auto; } thead th { border-bottom:1px solid #000; } .foot a { color:inherit; text-decoration:underline; } } </style> </head> <body> <div class="wrap"> <div class="topbar"> <div class="brand"> <svg width="29" height="29" viewBox="0 0 32 32" aria-hidden="true"><rect x="6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="14.3" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="22.6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><line x1="4" y1="24.5" x2="28" y2="7.5" stroke="#bc3e12" stroke-width="3.2" stroke-linecap="round"/></svg> <span class="wordmark serif">LicenseMeter Scan</span> </div> <span class="pill">Read-only</span> </div> <p class="meta" id="meta"></p> <div class="hero"> <div class="hero-label">Identified Microsoft 365 license waste</div> <div class="hero-num serif" id="heroNum"></div> </div> <div class="cards"> <div class="card"><div class="card-num serif" id="cardAnnual"></div><div class="card-lbl">identified per year</div></div> <div class="card"><div class="card-num serif" id="cardAccounts"></div><div class="card-lbl">accounts flagged</div></div> <div class="card"><div class="card-num serif" id="cardUsers"></div><div class="card-lbl">users scanned</div></div> </div> <div class="prices-hd"> <h2>Licenses in your tenant <span class="hint">edit any price to update the figures</span></h2> <div class="controls"> <label class="curlbl">Currency <select id="curSel"></select></label> </div> </div> <table> <thead><tr><th>License</th><th>SKU part number</th><th class="num">Assigned</th><th class="num">Available</th><th class="num">Price / seat</th><th class="num">Monthly (assigned)</th></tr></thead> <tbody id="invRows"></tbody> </table> <div id="report"> <h2>Waste by category</h2> <table><thead><tr><th>Category</th><th class="num">Users</th><th class="num">Monthly</th></tr></thead><tbody id="catRows"></tbody></table> <h2>Waste by license</h2> <table><thead><tr><th>License</th><th class="num">Seats</th><th class="num">Monthly</th></tr></thead><tbody id="skuRows"></tbody></table> <h2>Flagged accounts</h2> <table><thead><tr><th>Name</th><th>Account</th><th>Finding</th><th>Last activity / detail</th><th>License</th><th class="num">Monthly</th></tr></thead><tbody id="acctRows"></tbody></table> </div> <p class="clear" id="empty" style="display:none">No license waste found. Every paid license is assigned to an active, enabled account. Your full license inventory is shown above.</p> <p class="foot">Prices start from public list-price estimates; edit any of them above to fit your contract. Read-only scan, generated locally. Nothing left your tenant. For continuous, cross-vendor monitoring see <a href="https://www.licensemeter.com">licensemeter.com</a>.</p> </div> <script id="lm-data" type="application/json">__LM_DATA__</script> <script> (function () { var data = JSON.parse(document.getElementById('lm-data').textContent); var cur = data.currency || 'EUR'; function $(id) { return document.getElementById(id); } function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function fmt(cents) { return cur + ' ' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function num(n) { return Number(n || 0).toLocaleString('en-US'); } $('meta').textContent = 'Tenant: ' + data.tenant + ' \u00b7 Users scanned: ' + data.usersScanned + ' \u00b7 Activity: ' + (data.activitySource || (data.p1 ? 'sign-in logs' : 'unavailable')) + ' \u00b7 Generated: ' + data.generated; // License inventory (always shown) doubles as the editable price table. var inv = (data.inventory || []).slice().sort(function (a, b) { return (a.name || a.part).localeCompare(b.name || b.part); }); var invByPart = {}; inv.forEach(function (it) { invByPart[it.part] = it; }); $('invRows').innerHTML = inv.map(function (it) { var avail = it.available >= 100000 ? '<span class="dim">unlimited</span>' : num(it.available); return '<tr><td>' + esc(it.name || it.part) + '</td><td class="mono">' + esc(it.part) + '</td>' + '<td class="num">' + num(it.assigned) + '</td><td class="num">' + avail + '</td>' + '<td class="num"><input type="number" min="0" step="0.01" data-sku="' + esc(it.part) + '" value="' + (it.amount || 0) + '">' + (it.known ? '' : '<span class="needs">set price</span>') + '</td>' + '<td class="num" data-cost="' + esc(it.part) + '"></td></tr>'; }).join(''); // [code, name, region]; region drives the flag emoji (EUR gives the EU/European flag). var currencies = [ ['USD', 'US Dollar', 'US'], ['EUR', 'Euro', 'EU'], ['GBP', 'British Pound', 'GB'], ['CAD', 'Canadian Dollar', 'CA'], ['AUD', 'Australian Dollar', 'AU'], ['CHF', 'Swiss Franc', 'CH'], ['JPY', 'Japanese Yen', 'JP'], ['INR', 'Indian Rupee', 'IN'], ['BRL', 'Brazilian Real', 'BR'], ['SEK', 'Swedish Krona', 'SE'], ['NOK', 'Norwegian Krone', 'NO'], ['DKK', 'Danish Krone', 'DK'], ['SGD', 'Singapore Dollar', 'SG'], ['NZD', 'New Zealand Dollar', 'NZ'], ['ZAR', 'South African Rand', 'ZA'], ['AED', 'UAE Dirham', 'AE'] ]; // Build a flag emoji from a 2-letter region code via regional-indicator code points (keeps the source ASCII). function flagOf(cc) { if (!cc) return ''; return cc.toUpperCase().replace(/[A-Z]/g, function (ch) { return String.fromCodePoint(0x1F1E6 + ch.charCodeAt(0) - 65); }); } if (!currencies.some(function (c) { return c[0] === cur; })) currencies.unshift([cur, cur, '']); var curSel = $('curSel'); curSel.innerHTML = currencies.map(function (c) { var f = flagOf(c[2]); return '<option value="' + c[0] + '"' + (c[0] === cur ? ' selected' : '') + '>' + (f ? f + ' ' : '') + c[0] + ' (' + c[1] + ')</option>'; }).join(''); curSel.addEventListener('change', function () { cur = curSel.value; recompute(); }); function readPrices() { var p = {}, inputs = document.querySelectorAll('input[data-sku]'); for (var i = 0; i < inputs.length; i++) { var v = parseFloat(inputs[i].value); p[inputs[i].getAttribute('data-sku')] = isNaN(v) ? 0 : Math.round(v * 100); } return p; } var order = ['DisabledLicensed', 'InactiveLicensed', 'NeverSignedInLicensed', 'RedundantLicensed']; function recompute() { var prices = readPrices(); // Inventory: monthly cost of assigned seats, live. var costCells = document.querySelectorAll('td[data-cost]'); for (var i = 0; i < costCells.length; i++) { var part = costCells[i].getAttribute('data-cost'); var it = invByPart[part]; costCells[i].textContent = fmt((prices[part] || 0) * (it ? it.assigned : 0)); } // Waste, from the findings. var total = 0, byRule = {}, ruleUsers = {}, bySku = {}, accounts = {}; data.findings.forEach(function (f) { var c = prices[f.sku] || 0; total += c; byRule[f.rule] = (byRule[f.rule] || 0) + c; (ruleUsers[f.rule] = ruleUsers[f.rule] || {})[f.upn] = 1; if (!bySku[f.sku]) bySku[f.sku] = { count: 0, cents: 0, name: f.skuName }; bySku[f.sku].count++; bySku[f.sku].cents += c; accounts[f.upn] = 1; }); $('heroNum').innerHTML = fmt(total) + ' <span class="per">/ month</span>'; $('cardAnnual').textContent = fmt(total * 12); $('cardAccounts').textContent = Object.keys(accounts).length; $('cardUsers').textContent = data.usersScanned; if (data.findings.length === 0) { $('report').style.display = 'none'; $('empty').style.display = ''; return; } $('catRows').innerHTML = order.filter(function (r) { return ruleUsers[r]; }).map(function (r) { return '<tr><td>' + esc(data.ruleLabels[r] || r) + '</td><td class="num">' + Object.keys(ruleUsers[r]).length + '</td><td class="num">' + fmt(byRule[r] || 0) + '</td></tr>'; }).join(''); $('skuRows').innerHTML = Object.keys(bySku).sort(function (a, b) { return bySku[b].cents - bySku[a].cents; }).map(function (s) { return '<tr><td>' + esc(bySku[s].name || s) + '</td><td class="num">' + bySku[s].count + '</td><td class="num">' + fmt(bySku[s].cents) + '</td></tr>'; }).join(''); var rows = data.findings.map(function (f) { return { f: f, c: prices[f.sku] || 0 }; }); rows.sort(function (a, b) { return (b.c - a.c) || a.f.name.localeCompare(b.f.name); }); $('acctRows').innerHTML = rows.map(function (x) { var guest = x.f.guest ? '<span class="badge">guest</span>' : ''; return '<tr><td>' + esc(x.f.name) + '</td><td>' + esc(x.f.upn) + guest + '</td><td>' + esc(x.f.label) + '</td><td>' + esc(x.f.last) + '</td><td>' + esc(x.f.skuName) + '</td><td class="num">' + fmt(x.c) + '</td></tr>'; }).join(''); } document.addEventListener('input', function (e) { if (e.target && e.target.matches && e.target.matches('input[data-sku]')) recompute(); }); recompute(); })(); </script> </body> </html> '@ return $template.Replace('__LM_DATA__', $dataJson) } function Initialize-LMParentDirectory { # Ensure the parent folder of an output path exists, so -Output* into a new subfolder doesn't fail. param([Parameter(Mandatory)][string]$Path) $parent = Split-Path -Parent $Path if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } } function Write-LMReport { param( $Summary, $Findings, $Inventory, [string]$TenantLabel = 'your tenant', [string]$OutputCsv, [string]$OutputJson, [string]$OutputHtml, [switch]$NoConsole ) $reportPath = if ($OutputHtml) { $OutputHtml } elseif ($OutputCsv) { $OutputCsv } elseif ($OutputJson) { $OutputJson } else { $null } if (-not $NoConsole) { Format-LMConsoleReport -Summary $Summary -TenantLabel $TenantLabel -ReportPath $reportPath } foreach ($out in @($OutputCsv, $OutputJson, $OutputHtml)) { if ($out) { Initialize-LMParentDirectory -Path $out } } if ($OutputCsv) { ConvertTo-LMFindingRow -Findings $Findings -Currency $Summary.Currency | Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding utf8 } if ($OutputJson) { [pscustomobject]@{ generatedAtUtc = ([datetimeoffset]::UtcNow).ToString('o') tenant = $TenantLabel summary = $Summary findings = @($Findings) inventory = @($Inventory) } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputJson -Encoding utf8 } if ($OutputHtml) { Get-LMHtmlReport -Summary $Summary -Findings $Findings -Inventory $Inventory -TenantLabel $TenantLabel | Set-Content -LiteralPath $OutputHtml -Encoding utf8 } } # ---------------------------------------------------------------------------- # Price-file resolution # ---------------------------------------------------------------------------- function Get-LMPriceStorePath { # Per-user location for an optional reusable prices.json, independent of the working directory. # Cross-platform via ApplicationData. $base = [Environment]::GetFolderPath('ApplicationData') if (-not $base) { $base = Join-Path $HOME '.config' } return (Join-Path (Join-Path $base 'LicenseMeter') 'prices.json') } function Resolve-LMPriceFilePath { param([string]$PriceFile) if ($PriceFile) { if (-not (Test-Path -LiteralPath $PriceFile)) { throw "Price file not found: $PriceFile" } return (Resolve-Path -LiteralPath $PriceFile).Path } $local = Join-Path (Get-Location).Path 'prices.json' if (Test-Path -LiteralPath $local) { return (Resolve-Path -LiteralPath $local).Path } $store = Get-LMPriceStorePath if ($store -and (Test-Path -LiteralPath $store)) { return (Resolve-Path -LiteralPath $store).Path } foreach ($candidate in @( (Join-Path $PSScriptRoot '..' 'data' 'prices.sample.json'), (Join-Path $PSScriptRoot 'data' 'prices.sample.json'))) { if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path } } throw "No price file found. Provide -PriceFile or place prices.json in the working directory." } function Get-LMTenantLabel { # Prefer the tenant's default verified domain for a readable header; fall back to the # tenant id, then a generic label. Never fails the scan if /organization is unavailable. param($Context) try { $org = @(Invoke-LMGraphList -Uri "$script:GraphBase/organization?`$select=displayName,verifiedDomains") if ($org.Count -gt 0) { $domains = @(Get-LMProp $org[0] 'verifiedDomains') $pick = $domains | Where-Object { (Get-LMProp $_ 'isDefault') -eq $true } | Select-Object -First 1 if (-not $pick) { $pick = $domains | Where-Object { (Get-LMProp $_ 'isInitial') -eq $true } | Select-Object -First 1 } if (-not $pick -and $domains.Count -gt 0) { $pick = $domains[0] } $name = if ($pick) { Get-LMProp $pick 'name' } else { $null } if ($name) { return $name } } } catch { Write-Verbose "Tenant domain lookup failed: $($_.Exception.Message)" } $tid = Get-LMProp $Context 'TenantId' if ($tid) { return $tid } return 'your tenant' } # ---------------------------------------------------------------------------- # Orchestrator (public) # ---------------------------------------------------------------------------- function Invoke-LicenseMeterScan { <# .SYNOPSIS Scan a Microsoft 365 tenant for license waste and write an interactive report. .DESCRIPTION Read-only. Signs in via MgGraphCommunity if not already connected, reads users and subscribed SKUs, applies the waste rules, and writes a self-contained, interactive HTML report: prices are editable in the browser and totals recalculate live (use -PriceFile to set permanent prices). On completion it prints the report path. Use -PassThru to also return the summary object, -ShowConsole for a text summary in the terminal, -Quiet to print nothing. .PARAMETER TenantId Optional tenant id or domain to sign in against. For multi-tenant admins. .PARAMETER ClientId Optional app (client) id, for organizations that block the default Microsoft Graph PowerShell public client. No app registration is needed otherwise. .PARAMETER UseDeviceCode Sign in with the device-code flow instead of a system browser (headless/remote sessions). .PARAMETER InactiveDays Days without activity before a licensed account counts as inactive. Default 90. .PARAMETER PriceFile Path to a prices.json. Otherwise ./prices.json, then your saved prices, then the bundled list. .PARAMETER Currency Override the price book's currency code (relabels figures; no FX conversion). .PARAMETER OutputHtml Where to write the interactive HTML report. Defaults to ./licensemeter-scan.html. .PARAMETER OutputCsv Also write one CSV row per finding to this path. .PARAMETER OutputJson Also write structured findings plus the run summary to this path. .PARAMETER ExcludeGuests Skip guest (external) accounts entirely, so only members are scanned. .PARAMETER ShowConsole Also print a compact text summary in the terminal. .PARAMETER Quiet Print nothing, not even the report path. .PARAMETER PassThru Return the summary object (for scripting). .EXAMPLE LicenseMeterScan .EXAMPLE LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html .EXAMPLE LicenseMeterScan -PriceFile ./prices.json -OutputCsv ./waste.csv .EXAMPLE LicenseMeterScan -ExcludeGuests -Quiet -PassThru #> [CmdletBinding()] param( [string]$TenantId, [string]$ClientId, [switch]$UseDeviceCode, [int]$InactiveDays = 90, [string]$PriceFile, [string]$Currency, [string]$OutputHtml, [string]$OutputCsv, [string]$OutputJson, [switch]$ExcludeGuests, [switch]$ShowConsole, [switch]$Quiet, [switch]$PassThru ) # Always produce an interactive HTML report; default it to the working directory so the # run ends with a clickable result the user opens to review and edit prices in. if (-not $OutputHtml) { $OutputHtml = Join-Path (Get-Location).Path 'licensemeter-scan.html' } $priceFilePath = Resolve-LMPriceFilePath -PriceFile $PriceFile $book = Get-LMPriceBook -Path $priceFilePath if ($Currency) { $book.Currency = $Currency } if (-not (Test-LMConnected)) { $connectParams = @{} if ($TenantId) { $connectParams['TenantId'] = $TenantId } if ($ClientId) { $connectParams['ClientId'] = $ClientId } if ($UseDeviceCode) { $connectParams['UseDeviceCode'] = $true } Connect-LMGraph @connectParams | Out-Null } $context = Get-MgGraphCommunityContext Assert-LMGraphContextReadOnly -Context $context $tenantLabel = Get-LMTenantLabel $context $skus = Get-LMSubscribedSku $priceMap = Resolve-LMPriceMap -SubscribedSkus $skus -PriceBook $book $inventory = @(Get-LMInventory -SubscribedSkus $skus -PriceMap $priceMap) $userResult = Get-LMUser -InactiveDays $InactiveDays $users = @($userResult.Users) $hasSignIn = $userResult.HasSignInData $activitySrc = [string]$userResult.ActivitySource $activityMap = $userResult.ActivityByUpn if ($ExcludeGuests) { $users = @($users | Where-Object { [string](Get-LMProp $_ 'userType') -ne 'Guest' }) } $asOf = [datetimeoffset]::UtcNow # R1-R3 (per-user activity rules), then R4 (redundant suite overlap) for accounts not already flagged. $primary = @(Invoke-LMRule -Users $users -PriceMap $priceMap -AsOf $asOf ` -InactiveDays $InactiveDays -HasSignInData $hasSignIn -GraceDays $script:NeverSignedInGraceDays ` -ActivityByUpn $activityMap) $flaggedIds = @($primary | Select-Object -ExpandProperty UserId -Unique) $redundant = @(Get-LMRedundantFinding -Users $users -PriceMap $priceMap -ExcludeUserIds $flaggedIds) $findings = @($primary) + @($redundant) $summary = Get-LMScanSummary -Findings $findings -UsersScanned (@($users).Count) ` -Currency $book.Currency -HasSignInData $hasSignIn -InactiveDays $InactiveDays -ActivitySource $activitySrc Write-LMReport -Summary $summary -Findings $findings -Inventory $inventory -TenantLabel $tenantLabel ` -OutputCsv $OutputCsv -OutputJson $OutputJson -OutputHtml $OutputHtml -NoConsole:(-not $ShowConsole) if (-not $Quiet) { $resolved = (Resolve-Path -LiteralPath $OutputHtml).Path Write-Host '' Write-Host ' LicenseMeter Scan complete. Open your report:' -ForegroundColor Green Write-Host (" {0}" -f $resolved) -ForegroundColor Cyan Write-Host '' } if ($PassThru) { return $summary } } Set-Alias -Name LicenseMeterScan -Value Invoke-LicenseMeterScan Export-ModuleMember -Function 'Invoke-LicenseMeterScan', 'Connect-LMGraph' -Alias 'LicenseMeterScan' |