Give a user a Microsoft 365 licence, or take one back, after checking free seats
Build a workflow action that assigns a Microsoft 365 licence to a user, or removes one, so MSPs and IT teams can handle licence requests by asking SuperIT.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | Microsoft Graph credential |
| Suggested approval | Require approval from superit:admins for every run |
| Category | User lifecycle |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and its script hasn't been tested end to end in a SuperIT tenant. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, adjust it together, then test it on a test account or device. Test in the action editor runs the script for real.
Why use an action instead of asking SuperIT
SuperIT reads Microsoft 365 through your Microsoft 365 connection, but that connection is read-only, so SuperIT can't change anything in the tenant itself. Assigning or removing a licence is a write, which only an action with a delegated Microsoft Graph credential can make. The action checks for a free seat and a usage location before it assigns, so a request fails with a clear reason instead of half-applying.
To check which licences a user holds, or how many of each the tenant has free, just ask SuperIT in chat.
When to use it
- "Can Priya have a Visio licence?"
- "Give [email protected] Microsoft 365 Business Premium."
- "Tom moved to a frontline role, swap his E3 for F3." (run it twice: remove, then assign)
What the action does
- Checks the inputs and that the user exists.
- Looks up the licence by its product code (for example
SPE_E3) among the tenant's subscriptions. - When assigning: checks that the user has a usage location (or sets the one you give), that they don't already have the licence, and that a seat is free. If the assignment then fails, it clears the usage location it set.
- When removing: checks that the licence is assigned directly, not through a group.
- Assigns or removes the licence and prints the seats left.
Removing a licence can lead to data loss
Removing a licence that includes Exchange Online starts Microsoft's clock on the mailbox: Microsoft keeps it for about 30 days and then deletes its contents. OneDrive and other services have their own rules for unlicensed accounts. Before you remove a licence from someone who still needs their mail or files, convert the mailbox to a shared mailbox or move the data.
What it doesn't do: it doesn't buy licences or add seats, turn individual services inside a licence on or off, or change group-based licensing. Microsoft doesn't let a direct removal take away a licence that a group assigns; remove the user from that group instead.
Before you begin
- A Microsoft Graph credential for user administration, named
M365 Graph, with the scopesLicenseAssignment.ReadWrite.All User.ReadWrite.All.User.ReadWrite.Allis needed only to set a missing usage location; otherwiseUser.Read.Allis enough. The admin account that signed in needs License Administrator or User Administrator. Use this credential only for user-administration actions, and give security, reporting and mail actions their own credentials. See Automate Microsoft 365 with workflows. - These scopes come from Microsoft's documentation and haven't been tested with SuperIT. If reading the tenant's licences or setting the usage location fails with
Authorization_RequestDeniedor403, see If something goes wrong. - An authorization policy with one rule: Specific groups
superit:engineers,superit:admins→ Require approval fromsuperit:admins. Licences cost money, and removing one can lose data, so keep an approver. Don't add an Anyone rule: the script doesn't receive the requester's identity, so an end user could change someone else's licences. - The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.
Build it with SuperIT
Create a workflow action called "Assign or remove Microsoft 365 licence".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Graph.
Inputs:
- user_upn (email, required)
- sku_part_number (string, required) - licence product code such as SPE_E3
- operation (select, required, choices: assign, remove)
- usage_location (string, optional) - two-letter country code, used if the user has none
It should check free seats before assigning, clear a usage location it set if the
assignment fails, refuse to remove group-assigned licences, and print the seats left.
Authorization policy: engineers and admins, every run approved by superit:admins.
SuperIT repeats the brief back, drafts the script, checks it, and creates the action as a draft. Review the script in Workflows → Actions, test it, then publish it.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
user_upn |
Yes | [email protected] |
|
sku_part_number |
string | Yes | SPB (Business Premium) |
operation |
select (choices assign, remove) |
Yes | assign |
usage_location |
string | No | GB |
The main function at the top lists every step in order; each function below it does one of them, and the last line runs it with the action's inputs.
# Goal: assign or remove one Microsoft 365 licence for a user, checking free seats, usage location
# and group assignment first, and output the change and the seats left.
$token = $env:CREDENTIAL_M365_GRAPH_TOKEN
if (-not $token) { throw "Link the M365 Graph credential to this action." }
$headers = @{ Authorization = "Bearer $token" }
$baseUrl = 'https://graph.microsoft.com/v1.0'
function Invoke-LicenceChange {
param([string]$UserUpn, [string]$SkuPartNumber, [string]$Operation, [string]$UsageLocation)
$upn = $UserUpn.Trim()
$code = $SkuPartNumber.Trim()
$op = $Operation.Trim().ToLower()
Assert-Inputs -Upn $upn -Code $code -Operation $op
$sku = Get-TenantSku -Code $code
$user = Get-TargetUser -Upn $upn
$state = @($user.licenseAssignmentStates | Where-Object skuId -eq $sku.skuId)
$locationSet = $null
if ($op -eq 'assign') {
if ($state) { Write-NoChange -User $user -Reason "already has $code"; return }
Assert-FreeSeat -Sku $sku -Code $code
if (-not $user.usageLocation) { $locationSet = Set-UsageLocation -User $user -Upn $upn -Location $UsageLocation }
$body = @{ addLicenses = @(@{ skuId = $sku.skuId }); removeLicenses = @() }
} else {
if (-not $state) { Write-NoChange -User $user -Reason "doesn't have $code"; return }
Assert-DirectAssignment -State $state -Code $code -Upn $upn
$body = @{ addLicenses = @(); removeLicenses = @($sku.skuId) }
}
try {
Update-UserLicences -UserId $user.id -Body $body
} catch {
# Put the usage location back, so a failed assignment leaves the user as it found them.
if ($locationSet) { Undo-UsageLocation -UserId $user.id -Upn $upn }
Write-Error "Failed to change licences for '$upn': $_"
exit 1
}
$left = Get-FreeSeats -SkuId $sku.skuId
Write-LicenceSummary -User $user -Upn $upn -Code $code -Operation $op -LocationSet $locationSet -SeatsFree $left
# $script: because the runner returns the script-level $result, not a function's local copy.
$script:result = @{ user = $upn; licence = $code; operation = $op; seats_free = $left }
}
function Assert-Inputs {
param([string]$Upn, [string]$Code, [string]$Operation)
if ($Upn -notmatch '^[^@\s]+@[^@\s]+$') { throw "user_upn must be a sign-in address." }
if (-not $Code) { throw "sku_part_number is required." }
if ($Operation -notin 'assign', 'remove') { throw "operation must be assign or remove." }
}
function Get-TenantSku {
param([string]$Code)
try {
$skus = @(Get-GraphCollection -Uri "$baseUrl/subscribedSkus")
} catch {
Write-Error "Failed to read the tenant's licences: $_"
exit 1
}
$sku = $skus | Where-Object skuPartNumber -eq $Code
if (-not $sku) { throw "No $Code licence in this tenant. Available: $(($skus.skuPartNumber) -join ', ')" }
$sku
}
function Get-TargetUser {
param([string]$Upn)
$select = 'id,displayName,usageLocation,licenseAssignmentStates'
try {
Invoke-RestMethod -Uri "$baseUrl/users/$([uri]::EscapeDataString($Upn))?`$select=$select" -Headers $headers
} catch {
Write-Error "Failed to find user '$Upn': $_"
exit 1
}
}
function Assert-FreeSeat {
param($Sku, [string]$Code)
if ($Sku.prepaidUnits.enabled - $Sku.consumedUnits -lt 1) {
throw "No free $Code seats ($($Sku.consumedUnits) of $($Sku.prepaidUnits.enabled) used)."
}
}
function Set-UsageLocation {
param($User, [string]$Upn, [string]$Location)
$loc = $Location.Trim().ToUpper()
if ($loc -notmatch '^[A-Z]{2}$') { throw "$Upn has no usage location. Run again with usage_location, such as GB." }
try {
Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$($User.id)" -Headers $headers `
-ContentType 'application/json' -Body (@{ usageLocation = $loc } | ConvertTo-Json) | Out-Null
} catch {
Write-Error "Failed to set the usage location for '$Upn': $_"
exit 1
}
$loc
}
function Undo-UsageLocation {
param([string]$UserId, [string]$Upn)
try {
Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$UserId" -Headers $headers `
-ContentType 'application/json' -Body (@{ usageLocation = $null } | ConvertTo-Json) | Out-Null
} catch {
Write-Output "Needs attention : couldn't clear the usage location this run set for $($Upn): $($_.Exception.Message)"
}
}
function Assert-DirectAssignment {
param($State, [string]$Code, [string]$Upn)
if ($State | Where-Object assignedByGroup) {
throw "$Code is assigned to $Upn through a group. Remove them from that group instead."
}
}
function Update-UserLicences {
param([string]$UserId, [hashtable]$Body)
Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$UserId/assignLicense" -Headers $headers `
-ContentType 'application/json' -Body ($Body | ConvertTo-Json -Depth 4) | Out-Null
}
function Get-FreeSeats {
param([string]$SkuId)
$after = Get-GraphCollection -Uri "$baseUrl/subscribedSkus" | Where-Object skuId -eq $SkuId
$after.prepaidUnits.enabled - $after.consumedUnits
}
function Get-GraphCollection {
param([string]$Uri)
$items = @()
while ($Uri) {
$page = Invoke-RestMethod -Uri $Uri -Headers $headers
$items += $page.value
$Uri = $page.'@odata.nextLink'
}
$items
}
function Write-NoChange {
param($User, [string]$Reason)
Write-Output "=== NO CHANGE ==="
Write-Output "User : $($User.displayName)"
Write-Output "Reason : $Reason"
}
function Write-LicenceSummary {
param($User, [string]$Upn, [string]$Code, [string]$Operation, [string]$LocationSet, [int]$SeatsFree)
Write-Output "=== LICENCE $(if ($Operation -eq 'assign') { 'ASSIGNED' } else { 'REMOVED' }) ==="
Write-Output "User : $($User.displayName) ($Upn)"
Write-Output "Licence : $Code"
if ($LocationSet) { Write-Output "Usage location : set to $LocationSet" }
Write-Output "Seats free : $SeatsFree"
}
Invoke-LicenceChange -UserUpn $ctx.inputs.user_upn -SkuPartNumber $ctx.inputs.sku_part_number `
-Operation $ctx.inputs.operation -UsageLocation $ctx.inputs.usage_location
Test it
Test runs for real
Test skips authorization and approval, uses the real M365 Graph credential, and really assigns or removes the licence. Use a test user and a licence you have a spare seat for.
The script is untested, so treat your first test runs as debugging, and don't publish the action until a test run does exactly what you expect.
- On the action, select Test. Enter a test user, a licence code and
assign. - Read the This is not a dry run. warning, tick "I understand this is a real execution that may change real systems." and select Start test run.
- Check Stdout, then run the test again with
remove.
Use it
Publish the action. Engineers ask SuperIT, for example "Give Jane a Business Premium licence". Users and admins rarely know product codes, so SuperIT can first read the tenant's licences ("Which licences does this tenant have, and how many are free?") and pick the right code before it runs the action. The run waits for an admin to approve it.
For MSPs, publish to managed teams from the Publish dialog; an engineer or admin in each team authorizes its own M365 Graph credential.
Variations
- Let end users request software licences such as Visio or Project. Make a separate copy of the action whose
sku_part_numberis a select input listing only those licences, add an Anyone → Require approval fromsuperit:adminsrule to its policy, and have the approver check that the account belongs to the person who asked. - Accept a "from" and "to" licence and swap them in one
assignLicensecall. - Add or remove the user from a licensing group instead, if your tenant uses group-based licensing. See Add or remove group members.