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.0.0' $script:GraphBase = 'https://graph.microsoft.com/beta' $script:UserSelect = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime,signInActivity' $script:UserSelectNoSignIn = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime' $script:DefaultScopes = @('User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All') $script:NeverSignedInGraceDays = 30 $script:RuleLabels = @{ DisabledLicensed = 'Disabled account, still licensed' InactiveLicensed = 'Inactive {0}+ days, still licensed' NeverSignedInLicensed = 'Never signed in, still licensed' } $script:RuleOrder = @{ DisabledLicensed = 1 InactiveLicensed = 2 NeverSignedInLicensed = 3 } # Friendly display names for common SKUs. Falls back to the raw skuPartNumber. $script:SkuFriendlyName = @{ ENTERPRISEPACK = 'Office 365 E3' ENTERPRISEPREMIUM = 'Office 365 E5' SPE_E3 = 'Microsoft 365 E3' SPE_E5 = 'Microsoft 365 E5' SPB = 'Microsoft 365 Business Premium' O365_BUSINESS_PREMIUM = 'Microsoft 365 Business Standard' O365_BUSINESS_ESSENTIALS = 'Microsoft 365 Business Basic' EXCHANGESTANDARD = 'Exchange Online (Plan 1)' EXCHANGEENTERPRISE = 'Exchange Online (Plan 2)' POWER_BI_PRO = 'Power BI Pro' PROJECTPROFESSIONAL = 'Project Plan 3' 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' DEVELOPERPACK_E5 = 'Microsoft 365 E5 (Developer)' DEFENDER_ENDPOINT_P1 = 'Defender for Endpoint P1' WIN_DEF_ATP = 'Defender for Endpoint P2' } # ---------------------------------------------------------------------------- # 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)) { $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 $priced = $false if ($part -and $PriceBook.Prices.ContainsKey($part)) { $cents = [long]$PriceBook.Prices[$part] $priced = ($cents -gt 0) } $map[$skuId] = [pscustomobject]@{ SkuId = $skuId PartNumber = $part Cents = $cents Priced = $priced } } return $map } # ---------------------------------------------------------------------------- # Rule engine (pure, no I/O) # ---------------------------------------------------------------------------- function Invoke-LMRule { <# .SYNOPSIS Apply the v0.1 waste rules 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 ) $findings = [System.Collections.Generic.List[object]]::new() foreach ($user in @($Users)) { $licenses = @(Get-LMProp $user 'assignedLicenses') if ($licenses.Count -eq 0) { continue } $paid = [System.Collections.Generic.List[object]]::new() foreach ($lic in $licenses) { $skuId = Get-LMProp $lic 'skuId' if ($skuId -and $PriceMap.ContainsKey($skuId) -and $PriceMap[$skuId].Priced) { $paid.Add($PriceMap[$skuId]) } } if ($paid.Count -eq 0) { continue } $enabled = [bool](Get-LMProp $user 'accountEnabled') $rule = $null $lastActivity = $null $daysInactive = $null if (-not $enabled) { # R1: disabled account still holding a paid license. Needs no sign-in data. $rule = 'DisabledLicensed' } elseif ($HasSignInData) { $lastActivity = Get-LMLastActivity (Get-LMProp $user 'signInActivity') 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' } } } # When sign-in data is unavailable and the account is enabled, R2/R3 cannot be # evaluated in v0.1; the user is skipped and the report says so. if ($null -eq $rule) { continue } foreach ($p in $paid) { $findings.Add([pscustomobject]@{ Rule = $rule UserId = Get-LMProp $user 'id' UserPrincipalName = Get-LMProp $user 'userPrincipalName' DisplayName = Get-LMProp $user 'displayName' AccountEnabled = $enabled SkuId = $p.SkuId SkuPartNumber = $p.PartNumber SkuName = Get-LMSkuFriendlyName $p.PartNumber MonthlyCents = [long]$p.Cents LastActivity = if ($lastActivity) { $lastActivity.UtcDateTime.ToString('yyyy-MM-dd') } else { $null } DaysInactive = $daysInactive }) } } 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 ) $all = @($Findings) $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 } } [pscustomobject]@{ Currency = $Currency UsersScanned = $UsersScanned HasSignInData = $HasSignInData InactiveDays = $InactiveDays TotalMonthlyCents = $totalCents TotalAnnualCents = $totalCents * 12 FindingCount = $all.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 ', ')" } } # ---------------------------------------------------------------------------- # 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 $granted = Get-LMProp $ctx 'Scopes' if ($granted) { $writeish = foreach ($s in @($granted)) { if ($s -match '(?i)(write|\.send|\.manage|delete|fullcontrol)') { $s } } if ($writeish) { Write-Warning "The signed-in token carries non-read scopes ($($writeish -join ', ')). LicenseMeter Scan only reads." } } return $ctx } function Test-LMConnected { $ctx = $null try { $ctx = Get-MgGraphCommunityContext } catch { return $false } return [bool]$ctx } function Invoke-LMGraphList { # GET a Graph collection, following @odata.nextLink with host validation. param([Parameter(Mandatory)][string]$Uri) $results = [System.Collections.Generic.List[object]]::new() $next = $Uri 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" } $response = 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' } return $results.ToArray() } function Get-LMUser { # Fetch licensed-relevant user fields. Probes sign-in capability: if the tenant # lacks Entra ID P1 / AuditLog.Read.All, the signInActivity select fails and we # retry without it, reporting HasSignInData = $false. [CmdletBinding()] param() $uriFull = "$script:GraphBase/users?`$select=$script:UserSelect&`$top=999" try { $users = Invoke-LMGraphList -Uri $uriFull return [pscustomobject]@{ Users = $users; HasSignInData = $true } } 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 ($reason -notmatch '(?i)(premium|authorization_requestdenied|forbidden|\b403\b|does not have|tenant.*licen|insufficient|aadsts)') { 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 return [pscustomobject]@{ Users = $users; HasSignInData = $false } } } function Get-LMSubscribedSku { return Invoke-LMGraphList -Uri "$script:GraphBase/subscribedSkus" } # ---------------------------------------------------------------------------- # Reporting # ---------------------------------------------------------------------------- function Format-LMConsoleReport { param($Summary, [string]$TenantLabel = 'your tenant', [string]$ReportPath) $cur = $Summary.Currency $p1 = if ($Summary.HasSignInData) { 'yes' } else { 'no' } Write-Host '' Write-Host 'LicenseMeter Scan' -ForegroundColor White -NoNewline Write-Host " v$script:ModuleVersion" -ForegroundColor DarkGray Write-Host ("Tenant: {0} Users scanned: {1} Entra P1: {2}" -f $TenantLabel, $Summary.UsersScanned, $p1) -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 '' Write-Host 'Note: inactive and never-signed-in checks were skipped. This tenant lacks Entra ID P1 or AuditLog.Read.All (sign-in activity unavailable).' -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 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 } } } function ConvertTo-LMHtmlEncoded { param([string]$Text) if ($null -eq $Text) { return '' } return [System.Net.WebUtility]::HtmlEncode($Text) } function Get-LMHtmlReport { param($Summary, $Findings, [string]$TenantLabel = 'your tenant') $cur = $Summary.Currency $p1 = if ($Summary.HasSignInData) { 'yes' } else { 'no' } $generated = ([datetimeoffset]::UtcNow).ToString('yyyy-MM-dd HH:mm') + ' UTC' $ruleRows = foreach ($r in $Summary.ByRule) { "<tr><td>$(ConvertTo-LMHtmlEncoded $r.Label)</td><td class='num'>$($r.Users)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $r.MonthlyCents $cur))</td></tr>" } $skuRows = foreach ($s in $Summary.BySku) { "<tr><td>$(ConvertTo-LMHtmlEncoded $s.SkuName)</td><td class='num'>$($s.Count)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $s.MonthlyCents $cur))</td></tr>" } $sorted = @($Findings) | Sort-Object -Property @{ Expression = 'MonthlyCents'; Descending = $true }, @{ Expression = 'DisplayName'; Descending = $false } $findingRows = foreach ($f in $sorted) { $finding = switch ($f.Rule) { 'DisabledLicensed' { 'Disabled' } 'InactiveLicensed' { 'Inactive' } 'NeverSignedInLicensed' { 'Never signed in' } default { $f.Rule } } $lastSeen = if ($f.LastActivity) { $f.LastActivity } else { 'never' } "<tr><td>$(ConvertTo-LMHtmlEncoded $f.DisplayName)</td><td>$(ConvertTo-LMHtmlEncoded $f.UserPrincipalName)</td><td>$(ConvertTo-LMHtmlEncoded $finding)</td><td>$(ConvertTo-LMHtmlEncoded $lastSeen)</td><td>$(ConvertTo-LMHtmlEncoded $f.SkuName)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $f.MonthlyCents $cur))</td></tr>" } $flaggedAccounts = (@($Findings) | Select-Object -ExpandProperty UserId -Unique | Measure-Object).Count $monthly = ConvertTo-LMHtmlEncoded (Format-LMMoney $Summary.TotalMonthlyCents $cur) $annual = ConvertTo-LMHtmlEncoded (Format-LMMoney $Summary.TotalAnnualCents $cur) $tenant = ConvertTo-LMHtmlEncoded $TenantLabel if ($Summary.FindingCount -eq 0) { $bodyTables = ' <p class="clear">No licensed waste found. Every paid license is assigned to an active, enabled account.</p>' } else { $bodyTables = @" <h2>By category</h2> <table> <thead><tr><th>Category</th><th class="num">Users</th><th class="num">Monthly</th></tr></thead> <tbody> $($ruleRows -join "`n ") </tbody> </table> <h2>By license</h2> <table> <thead><tr><th>License</th><th class="num">Seats</th><th class="num">Monthly</th></tr></thead> <tbody> $($skuRows -join "`n ") </tbody> </table> <h2>Flagged accounts</h2> <table> <thead><tr><th>Name</th><th>Account</th><th>Finding</th><th>Last sign-in</th><th>License</th><th class="num">Monthly</th></tr></thead> <tbody> $($findingRows -join "`n ") </tbody> </table> "@ } @" <!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; } .wrap { max-width:800px; 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; } .brand svg { display:block; } .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; letter-spacing:0; } .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; } 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:8px 10px; border-bottom:1px solid var(--line); vertical-align:top; } td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; } .clear { font-size:1.1rem; margin-top:28px; } .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; } </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">Tenant: $tenant · Users scanned: $($Summary.UsersScanned) · Entra P1: $p1 · Generated: $generated</p> <div class="hero"> <div class="hero-label">Identified Microsoft 365 license waste</div> <div class="hero-num serif">$monthly <span class="per">/ month</span></div> </div> <div class="cards"> <div class="card"><div class="card-num serif">$annual</div><div class="card-lbl">identified per year</div></div> <div class="card"><div class="card-num serif">$flaggedAccounts</div><div class="card-lbl">accounts flagged</div></div> <div class="card"><div class="card-num serif">$($Summary.UsersScanned)</div><div class="card-lbl">users scanned</div></div> </div> $bodyTables <p class="foot">Figures use list prices unless a custom price book was supplied. 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> </body> </html> "@ } function Write-LMReport { param( $Summary, $Findings, [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 } 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) } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputJson -Encoding utf8 } if ($OutputHtml) { Get-LMHtmlReport -Summary $Summary -Findings $Findings -TenantLabel $TenantLabel | Set-Content -LiteralPath $OutputHtml -Encoding utf8 } } # ---------------------------------------------------------------------------- # Price-file resolution # ---------------------------------------------------------------------------- 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 $local } 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 report the result. .DESCRIPTION Read-only. Signs in via MgGraphCommunity if not already connected, reads users and subscribed SKUs, applies the v0.1 waste rules, and prints a summary. Optionally writes CSV, JSON, and HTML reports locally. .EXAMPLE Invoke-LicenseMeterScan .EXAMPLE Invoke-LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html #> [CmdletBinding()] param( [string]$TenantId, [string]$ClientId, [switch]$UseDeviceCode, [int]$InactiveDays = 90, [string]$PriceFile, [string]$Currency, [string]$OutputCsv, [string]$OutputJson, [string]$OutputHtml, [switch]$IncludeUnpriced ) $priceFilePath = Resolve-LMPriceFilePath -PriceFile $PriceFile $book = Get-LMPriceBook -Path $priceFilePath if ($Currency) { $book.Currency = $Currency } if ($priceFilePath -like '*prices.sample.json') { Write-Warning "Using sample list prices ($priceFilePath). Copy to prices.json and edit for your contract." } 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 $tenantLabel = Get-LMTenantLabel $context $skus = Get-LMSubscribedSku $priceMap = Resolve-LMPriceMap -SubscribedSkus $skus -PriceBook $book $userResult = Get-LMUser $users = $userResult.Users $hasSignIn = $userResult.HasSignInData $asOf = [datetimeoffset]::UtcNow $findings = Invoke-LMRule -Users $users -PriceMap $priceMap -AsOf $asOf ` -InactiveDays $InactiveDays -HasSignInData $hasSignIn -GraceDays $script:NeverSignedInGraceDays $summary = Get-LMScanSummary -Findings $findings -UsersScanned (@($users).Count) ` -Currency $book.Currency -HasSignInData $hasSignIn -InactiveDays $InactiveDays Write-LMReport -Summary $summary -Findings $findings -TenantLabel $tenantLabel ` -OutputCsv $OutputCsv -OutputJson $OutputJson -OutputHtml $OutputHtml if ($IncludeUnpriced) { $unpriced = foreach ($sku in @($skus)) { $skuId = Get-LMProp $sku 'skuId' $part = Get-LMProp $sku 'skuPartNumber' $consumed = Get-LMProp $sku 'consumedUnits' if ($skuId -and $priceMap.ContainsKey($skuId) -and -not $priceMap[$skuId].Priced -and ([int]$consumed -gt 0)) { [pscustomobject]@{ SkuPartNumber = $part; ConsumedUnits = [int]$consumed } } } if ($unpriced) { Write-Host 'Unpriced SKUs assigned in this tenant (add them to prices.json to include in the total):' foreach ($u in (@($unpriced) | Sort-Object SkuPartNumber)) { Write-Host (" {0,-34}{1,5} assigned" -f $u.SkuPartNumber, $u.ConsumedUnits) -ForegroundColor DarkGray } Write-Host '' } } return $summary } Export-ModuleMember -Function 'Invoke-LicenseMeterScan', 'Connect-LMGraph' |