<#
.SYNOPSIS
For every customer in your GDAP tenant list, finds which ones already have
Windows Server or SQL Server licensing sold to them via Partner Center CSP
subscriptions.
.DESCRIPTION
Reads the customer tenant list produced by Get-GDAPCustomerTenants.ps1,
signs into Partner Center ONCE as you/the partner (not per customer
tenant - Partner Center scopes calls by -CustomerId, unlike the Azure
RBAC/GDAP side which needs a context switch per tenant), and for each
customer lists their CSP subscriptions, keeping the ones whose offer name
matches a Server OS or SQL Server keyword and whose BillingType is
'License' (the model Windows Server/SQL Server licensing is sold under in
Partner Center today).
Each kept row is also tagged LicenseModel = 'Perpetual' or 'Subscription'
(see Get-LicenseCoreDetails). This matters downstream: Azure Hybrid Benefit
requires Software Assurance or an equivalent Software Subscription - a
straight Perpetual purchase does NOT qualify as BYOL cover, so
Test-LicenseCompliance-AllCustomers.ps1 and
Find-HybridBenefitOpportunities-AllCustomers.ps1 only count Subscription
rows toward AHB-eligible licensing on file.
Run in its own PowerShell process, kept separate from Az.Accounts and
Microsoft.Graph, to avoid the known assembly conflicts between these
SDKs' shared Azure.Identity/MSAL dependencies (same reason
Get-GDAPCustomerTenants.ps1 runs standalone).
Sign-in acquires one raw access token (New-PartnerAccessToken, using the
same well-known Microsoft public client ID Connect-PartnerCenter uses
internally) and feeds it into Connect-PartnerCenter, rather than calling
Connect-PartnerCenter interactively - doing both interactively in one
process throws "An item with the same key has already been added" from
the module's token cache. Keeping the raw token also lets this script
fall back to calling the Partner Center REST API directly for any
customer whose subscription list contains a status value the module's
typed model doesn't have an enum member for ("Requested value 'X' was
not found" - seen in the wild for 'Disabled', 'Expired', 'Benefit').
That's a module bug, not a per-customer issue: one unrecognized status
on ANY subscription throws for the customer's ENTIRE subscription list,
so without this fallback such a customer looks identical to "no license"
when the truth is "we don't know."
A customer erroring here (e.g. "not found" / access denied) usually means
they're not your direct Partner Center customer of record - e.g. a 2-tier
CSP customer sitting under a different top-tier distributor. Logged to
the errors CSV and skipped, not a script bug.
VALIDATE BEFORE TRUSTING AT SCALE: run this once, then check its output
against 2-3 customers you *know* have Windows Server/SQL Server licensing
sold, to confirm the offer-name keywords actually catch your catalog's
naming before relying on the full run's negative results.
.PARAMETER CustomerCsv
Path to the customer tenant list from Get-GDAPCustomerTenants.ps1.
.PARAMETER OutputCsv
Path to write the per-customer licensing findings to. Deliberately not
timestamped - it's a stable input consumed by
Find-HybridBenefitOpportunities-AllCustomers.ps1, same as GDAPCustomers.csv.
.PARAMETER ServerOSKeywords
Offer-name substrings (case-insensitive) that count as Server OS licensing.
.PARAMETER SqlKeywords
Offer-name substrings (case-insensitive) that count as SQL Server licensing.
.PARAMETER ExcludeSuspended
By default a Suspended subscription still counts as "licensing on file"
(the record exists, it's just currently suspended). Pass this switch to
count only Active subscriptions. Deleted subscriptions never count.
#>
[CmdletBinding()]
param(
[string]$CustomerCsv,
[string]$OutputCsv,
[string[]]$ServerOSKeywords = @('Windows Server'),
[string[]]$SqlKeywords = @('SQL Server'),
[switch]$ExcludeSuspended,
# Default is WAM (native Windows sign-in, supports passkey/Windows Hello) - only
# needed when this script is launched by automation with no window handle for WAM's
# broker UI to attach to (fails with "A window handle must be configured"). Running
# this yourself in your own terminal should just work without this switch.
[switch]$DeviceCode
)
$ErrorActionPreference = 'Stop'
# $PSScriptRoot can come back empty for -File invocations in this environment
# (reproduced directly - it's not just this script); fall back to $MyInvocation,
# which is reliable at script-body scope regardless of how the script was invoked.
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition }
if (-not $CustomerCsv) { $CustomerCsv = Join-Path $ScriptDir 'GDAPCustomers.csv' }
if (-not $OutputCsv) { $OutputCsv = Join-Path $ScriptDir 'PartnerCenterLicensing.csv' }
if (-not (Test-Path $CustomerCsv)) {
throw "Customer list not found at '$CustomerCsv'. Run Get-GDAPCustomerTenants.ps1 first."
}
Import-Module PartnerCenter -RequiredVersion 3.0.10 -ErrorAction Stop
$customers = Import-Csv -Path $CustomerCsv
if (-not $customers -or $customers.Count -eq 0) {
Write-Warning "No customer tenants in '$CustomerCsv'. Nothing to check."
return
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$errorsPath = Join-Path (Split-Path $OutputCsv -Parent) "PartnerCenterLicensing_Errors_$timestamp.csv"
$findings = [System.Collections.Generic.List[object]]::new()
$errors = [System.Collections.Generic.List[object]]::new()
$statusesToKeep = if ($ExcludeSuspended) { @('Active') } else { @('Active', 'Suspended') }
function Add-ErrorRow {
param($TenantId, $TenantName, $Message)
$errors.Add([pscustomobject]@{
TenantId = $TenantId
TenantName = $TenantName
Message = $Message
})
}
function Get-LicenseCategory {
param([string]$OfferName)
if (-not $OfferName) { return $null }
foreach ($kw in $ServerOSKeywords) { if ($OfferName -match [regex]::Escape($kw)) { return 'ServerOS' } }
foreach ($kw in $SqlKeywords) { if ($OfferName -match [regex]::Escape($kw)) { return 'SQL' } }
return $null
}
# Parses core-license detail out of the offer name for the compliance math in
# Test-LicenseCompliance-AllCustomers.ps1: edition (Standard/Datacenter/Enterprise),
# whether it's a CAL (never a core license - CALs are a separate requirement and
# must never be counted toward the per-VM core minimum), cores-per-unit (null
# when the offer name doesn't state a per-core count at all, e.g. legacy
# Server+CAL-model SKUs - those need manual review, not a guessed number), and
# LicenseModel (Perpetual vs Subscription - see below).
#
# LicenseModel matters because Azure Hybrid Benefit (BYOL) requires Software
# Assurance or an equivalent Software Subscription - a straight Perpetual purchase
# (no SA) does NOT qualify, per Microsoft's AHB terms. In the CSP catalog the two
# commerce models are distinguishable from the offer name alone: Software
# Subscription SKUs are explicitly annual and carry a "1 Year"/"1 year" suffix
# (confirmed against the live data: those rows also carry a real ~1-year-out
# CommitmentEndDate); Perpetual SKUs have no such suffix and report the
# 31/12/9999 sentinel (or a blank) CommitmentEndDate, meaning no recurring term
# at all. CALs are excluded from this either way since they're never core
# licenses regardless of commerce model.
function Get-LicenseCoreDetails {
param([string]$OfferName)
$isCal = $OfferName -match '\bCAL\b'
$edition =
if ($OfferName -match 'Datacenter') { 'Datacenter' }
elseif ($OfferName -match 'Enterprise') { 'Enterprise' }
elseif ($OfferName -match 'Standard') { 'Standard' }
else { 'Unknown' }
$coreMatch = [regex]::Match($OfferName, '(\d+)\s*[Cc]ore')
$coresPerUnit = if ($coreMatch.Success -and -not $isCal) { [int]$coreMatch.Groups[1].Value } else { $null }
$licenseModel = if ($OfferName -match '\b1\s*[Yy]ear\b') { 'Subscription' } else { 'Perpetual' }
[pscustomobject]@{ IsCal = $isCal; Edition = $edition; CoresPerUnit = $coresPerUnit; LicenseModel = $licenseModel }
}
# The well-known Microsoft public client ID Connect-PartnerCenter itself uses for interactive
# sign-in (found via reflection on Commands.ConnectPartnerCenter::PowerShellApplicationId).
# Reused here so the raw-REST fallback authenticates as the same client.
$partnerCenterAppId = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'
function Get-CustomerSubscriptionsRaw {
param([string]$CustomerId, [string]$AccessToken)
$headers = @{
Authorization = "Bearer $AccessToken"
'MS-RequestId' = [guid]::NewGuid().ToString()
'MS-CorrelationId' = [guid]::NewGuid().ToString()
Accept = 'application/json'
}
$resp = Invoke-RestMethod -Uri "https://api.partnercenter.microsoft.com/v1/customers/$CustomerId/subscriptions" -Headers $headers -Method Get -ErrorAction Stop
return $resp.items | ForEach-Object {
[pscustomobject]@{
OfferId = $_.offerId
OfferName = $_.offerName
BillingType = $_.billingType
Status = $_.status
Quantity = $_.quantity
SubscriptionId = $_.id
CommitmentEndDate = $_.commitmentEndDate
}
}
}
Write-Host "`n=== Sign in to Partner Center ===" -ForegroundColor Cyan
Write-Host "This is ONE sign-in as you/the partner - not per customer tenant." -ForegroundColor Cyan
if ($DeviceCode) {
Write-Host "You'll get a device code - open the printed URL in your normal browser and enter it there." -ForegroundColor Cyan
Write-Host "(A passkey/Windows Hello option should be available on that sign-in page if you have one registered.)" -ForegroundColor Cyan
$partnerToken = New-PartnerAccessToken -ApplicationId $partnerCenterAppId `
-Scopes 'https://api.partnercenter.microsoft.com/user_impersonation' -UseDeviceAuthentication
} else {
Write-Host "A browser sign-in window will open - use passkey/Windows Hello there if you have one registered." -ForegroundColor Cyan
$partnerToken = New-PartnerAccessToken -ApplicationId $partnerCenterAppId `
-Scopes 'https://api.partnercenter.microsoft.com/user_impersonation' -UseAuthorizationCode
}
Connect-PartnerCenter -AccessToken $partnerToken.AccessToken | Out-Null
Write-Host "Signed in to Partner Center." -ForegroundColor Green
Write-Host "Customer tenants to check: $($customers.Count)" -ForegroundColor Green
$idx = 0
$recoveredCount = 0
foreach ($customer in $customers) {
$idx++
$tenantLabel = if ($customer.TenantName) { $customer.TenantName } else { $customer.TenantId }
Write-Host "[$idx/$($customers.Count)] $tenantLabel" -ForegroundColor Yellow
$subs = $null
try {
$subs = Get-PartnerCustomerSubscription -CustomerId $customer.TenantId -ErrorAction Stop
} catch {
if ($_.Exception.Message -match "Requested value '.*' was not found") {
# Known PartnerCenter module bug: one subscription with a status value the typed
# model doesn't recognize throws for the customer's ENTIRE subscription list.
# Fall back to the raw REST API, which returns status as a plain string.
try {
$subs = Get-CustomerSubscriptionsRaw -CustomerId $customer.TenantId -AccessToken $partnerToken.AccessToken
$recoveredCount++
Write-Host " Recovered via raw REST fallback (module couldn't parse a subscription status)" -ForegroundColor DarkCyan
} catch {
Write-Host " Skipped - raw REST fallback also failed: $($_.Exception.Message)" -ForegroundColor DarkGray
Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Typed call failed (unrecognized status); REST fallback also failed: $($_.Exception.Message)"
continue
}
} else {
Write-Host " Skipped - $($_.Exception.Message)" -ForegroundColor DarkGray
Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message $_.Exception.Message
continue
}
}
foreach ($sub in $subs) {
# -ne is case-insensitive by default in PowerShell, so this matches both the typed
# model's 'License' and the raw REST fallback's lowercase 'license'.
if ($sub.BillingType.ToString() -ne 'License') { continue }
if ($sub.Status.ToString() -notin $statusesToKeep) { continue }
$category = Get-LicenseCategory -OfferName $sub.OfferName
if (-not $category) { continue }
$core = Get-LicenseCoreDetails -OfferName $sub.OfferName
$totalCores = if ($core.CoresPerUnit) { $core.CoresPerUnit * [int]$sub.Quantity } else { $null }
$isLapsed = $false
if ($sub.CommitmentEndDate) {
try { $isLapsed = ([datetime]$sub.CommitmentEndDate) -lt (Get-Date) } catch {}
}
$findings.Add([pscustomobject]@{
TenantId = $customer.TenantId
TenantName = $tenantLabel
LicenseCategory = $category
OfferId = $sub.OfferId
OfferName = $sub.OfferName
Edition = $core.Edition
IsCal = $core.IsCal
CoresPerUnit = $core.CoresPerUnit
LicenseModel = $core.LicenseModel
Quantity = $sub.Quantity
TotalCores = $totalCores
Status = $sub.Status
IsLapsed = $isLapsed
SubscriptionId = $sub.SubscriptionId
CommitmentEndDate = $sub.CommitmentEndDate
})
Write-Host " [$category] $($sub.OfferName) x$($sub.Quantity) ($($sub.Status))" -ForegroundColor Green
}
}
$findings | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
$errors | Export-Csv -Path $errorsPath -NoTypeInformation -Encoding UTF8
Write-Host "`n=== Done ===" -ForegroundColor Cyan
Write-Host "Customers checked : $($customers.Count)" -ForegroundColor Green
Write-Host "Matching Server OS/SQL license records : $($findings.Count)" -ForegroundColor Green
Write-Host "Recovered via REST fallback : $recoveredCount" -ForegroundColor DarkCyan
Write-Host "Customers with a lookup error : $($errors.Count)" -ForegroundColor Yellow
Write-Host "Licensing file (feeds the next script) : $OutputCsv" -ForegroundColor Green
Write-Host "Errors log : $errorsPath" -ForegroundColor Yellow
Disconnect-PartnerCenter | Out-Null