Remediation/EntraID/LICENSE-REMOVAL.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Removes the licenses of the source accounts, license by license, on decision (procedure LICENSE-REMOVAL). .DESCRIPTION The target is refused: this script only touches the sources. If the target is asked for, it stops before any action. A license still carries the mail of the account: in an -Apply run, a tenant is handled only if the row Accounts opened says Done in decisions\CUTOVER-STEPS.csv and if decisions\TENANT-CLOSURE.csv holds its complete written authorization. Otherwise, nothing for this tenant. Decision: decisions\LICENSE-REMOVAL.csv, one row per license of each source. Remove or Keep. A license with 10000 units or more is a free offer: no proposal. Each license removed takes back the accounts that hold it, read live, except protected accounts (emergency, rescue, migration team, kept accounts); Undo-TcRemediation gives it back to these accounts, with their disabled plans. A license assigned through a group refuses removal account by account: the action fails and names the accounts. In an -Apply run, the report subscriptions-<tenant>.csv gives the state and the next change date of each subscription, for the reseller. Canceling a subscription has no API: it stays to do with the reseller. #> $c = Open-TcRemediation if ('Target' -in $c.Tenants) { throw 'This is the target tenant: this script refuses to handle it. No action, no decision.' } function Get-LicenseAccount { <# Accounts that hold the license, except protected accounts. Filtered here: a Graph filter on assignedLicenses needs an advanced query. #> param([string]$Tenant, [string]$SkuId) $i = Get-TcRemediationTenant $Tenant return @(Invoke-TcGraph -Tenant $Tenant -Uri 'v1.0/users?$select=id,userPrincipalName,assignedLicenses&$top=999' -All | Where-Object { @((Get-TcMember $_ 'assignedLicenses') | Where-Object { (Get-TcMember $_ 'skuId') -eq $SkuId }).Count -and -not (Test-TcProtectedAccount (Get-TcMember $_ 'userPrincipalName') $i) }) } function Get-RemovalRefusal { <# What forbids removing the licenses of a tenant: the cutover must be over and its closure authorized in writing. #> param([string]$Tenant) $r = [System.Collections.Generic.List[string]]::new() if (-not (Test-TcCutoverStep -Tenant $Tenant -Step 'Accounts opened')) { $r.Add('the cutover is not recorded as done in the decisions') } $a = (Read-TcDecisionEntry -Name 'TENANT-CLOSURE' -Key 'Tenant')[$Tenant.ToLowerInvariant()] if (-not $a -or $a.'Decision' -ne 'Allowed' -or -not $a.'Decision maker' -or -not $a.Date -or -not ([string]$a.Note).Trim()) { $r.Add('no complete written authorization in TENANT-CLOSURE.csv') } return $r.ToArray() } $proposals = foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t $licenses = @(Get-TcRemediationTable $t 'licenses') $paid = @($licenses | Where-Object { [int]$_['Purchased'] -lt 10000 }) Write-TcRemediationNote ("{0} license(s), {1} of them paid: {2}" -f $licenses.Count, $paid.Count, (($paid | ForEach-Object { '{0} {1}/{2}' -f $_['SKU'], $_['Consumed'], $_['Purchased'] }) -join ', ')) foreach ($l in $licenses) { [ordered]@{ Tenant = $t; License = $l['SKU']; SkuId = $l['SkuId']; 'Purchased' = $l['Purchased']; 'Consumed' = $l['Consumed']; Proposal = $(if ([int]$l['Purchased'] -lt 10000) { 'Remove' } else { '' }) } } } $decisions = @(Get-TcDecision -Name 'LICENSE-REMOVAL' -Proposal @($proposals) -Key 'Tenant', 'License' -Choice 'Remove', 'Keep') foreach ($t in $c.Tenants) { $refusal = @(Get-RemovalRefusal $t) if ($refusal.Count) { if ($c.Apply) { Write-TcRemediationAlert ("{0}: licenses not removed, {1}." -f $t, ($refusal -join ' ; ')); continue } Write-TcRemediationNote ("{0}: to gather before the -Apply run: {1}" -f $t, ($refusal -join ' ; ')) } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Remove' }) { Add-TcBlockAction -Tenant $t -Service Graph -Object (Get-TcMember $d 'License') -Description ("Remove {0} from its {1} account(s) read in {2}" -f (Get-TcMember $d 'License'), (Get-TcMember $d 'Consumed'), $t) -Data @{ SkuId = (Get-TcMember $d 'SkuId') } ` -Test { param($a) $a.data.Accounts = @(Get-LicenseAccount -Tenant $a.tenant -SkuId $a.data.SkuId) return -not $a.data.Accounts.Count } ` -Block { param($a) $sku = $a.data.SkuId $undos = [System.Collections.Generic.List[object]]::new() $failures = [System.Collections.Generic.List[string]]::new() foreach ($u in $a.data.Accounts) { $uri = "v1.0/users/$((Get-TcMember $u 'id'))/assignLicense" $before = @((Get-TcMember $u 'assignedLicenses') | Where-Object { (Get-TcMember $_ 'skuId') -eq $sku }) | Select-Object -First 1 try { Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $uri -Body @{ addLicenses = @(); removeLicenses = @($sku) } | Out-Null $undos.Add(@{ type = 'Graph'; tenant = $a.tenant; method = 'POST'; uri = $uri; body = @{ addLicenses = @(@{ skuId = $sku; disabledPlans = @((Get-TcMember $before 'disabledPlans')) }); removeLicenses = @() } }) } catch { $failures.Add(("{0}: {1}" -f (Get-TcMember $u 'userPrincipalName'), (Get-TcErrorText $_))) } } $a.undo = $undos.ToArray() if ($failures.Count) { throw ("{0} account(s) out of {1} failed: {2}" -f $failures.Count, @($a.data.Accounts).Count, ($failures -join ' ; ')) } } } $removedOnes = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Remove' }) if ($removedOnes.Count) { Add-TcManualAction -Tenant $t -Object "Cancellation $t" -Description "Cancel the subscriptions of $t with the reseller" -Who 'Customer and reseller' -Instruction @( ("Subscriptions to cancel with the reseller: {0}." -f (($removedOnes | ForEach-Object { (Get-TcMember $_ 'License') }) -join ', ')), 'Rely on the subscriptions report for the dates. No API allows the cancellation.') } } Invoke-TcRemediationPlan -Verify { param($t) $subscriptions = @(Invoke-TcGraph -Tenant $t -Uri 'v1.0/directory/subscriptions' -All) [void](Export-TcRemediationReport -Name "subscriptions-$t" -Rows @($subscriptions | ForEach-Object { [ordered]@{ License = (Get-TcMember $_ 'skuPartNumber'); 'State' = (Get-TcMember $_ 'status'); Seats = (Get-TcMember $_ 'totalLicenses'); 'Test' = (Get-TcMember $_ 'isTrial'); 'Next change' = (Get-TcMember $_ 'nextLifecycleDateTime') } })) if (@(Get-RemovalRefusal $t).Count) { New-TcRemediationResult Info 'check skipped: this tenant was not handled'; return } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Remove' }) { $n = @(Get-LicenseAccount -Tenant $t -SkuId (Get-TcMember $d 'SkuId')).Count if ($n) { New-TcRemediationResult Blocking ("{0}: still held by {1} account(s)" -f (Get-TcMember $d 'License'), $n) } else { New-TcRemediationResult OK ("{0}: removed from all unprotected accounts" -f (Get-TcMember $d 'License')) } } } Close-TcRemediation |