Wednesday, July 29, 2020

Managing ADO Licenses from Azure DevOps CLI

My last post introduced using JMESPath with the az devops cli, which hopefully gave you some insight into use the az cli and the az devops extension. Today I want to highlight how you can easily pull the az devops cli into PowerShell to unlock some amazing scripting ability.

A good example of this is how we can use PowerShell + az devops cli to manage Azure DevOps User Licenses.

Background

Azure DevOps is a licensed product, and while you can have unlimited free Stakeholder licenses, any developer that needs access to code repositories needs a Basic license, which costs about $7 CAD / month.

It's important to note that this cost is not tied to usage, so if you've allocated licenses manually, you're essentially paying for it. Interestingly, this is not a fixed monthly cost, but prorated on a daily basis in the billing period. So if you can convert Basic licenses to Stakeholder licenses, you’ll only pay for the days in the billing period when the license was active.

If you establish a process to revoke licenses when they're not being used, you can save your organization a few dollars that would otherwise be wasted. It might not be much, but if you consider 10 user licenses for the year is about $840 – the costs do add up. something you could argue should be added to your end-of-year bonus.

Integrating the Azure DevOps CLI into PowerShell

To kick things off and to show how incredibly easy this is, let's start with this snippet:

param() {

}


$users = az devops user list | ConvertFrom-Json

Write-Host $users.totalCount
Write-Host $users.items[0].user.principalName

Boom. No special magic. We just call the az devops cli directly in our PowerShell script, converting the JSON result into an object by piping it through the ConvertFrom-Json commandlet. We can easily interrogate object properties and build up some conditional logic. Fun.

Use JMESPath to Simplify Results

While we could work with the results in this object directly, the result objects have a complex structure so I’m going to flatten the object down to make it easier to get at the properties we need. I only want the user’s email, license, and the dates when the user account was created and last accessed.

JMESPath makes this easy. If you missed the last post, go back and have a read to get familiar with this syntax.

function Get-AzureDevOpsUsers() {

    $query = "items[].{license:accessLevel.licenseDisplayName, email:user.principalName, dateCreated:dateCreated, lastAccessedDate:lastAccessedDate }"
    $users = az devops user list --query "$query" | ConvertFrom-Json

    return $users
}

Ok. That structure’s looking a lot easier to work with.

Finding Licenses to Revoke

Now all we need to do is find the licenses we want to convert from Basic to Stakeholder. Admittedly, this could create headaches for us if we're randomly taking away licenses that people might need in the future, but we should be safe if we target people who've never logged in or haven't logged in for 21 days.

I’m going to break this down into two separate functions. One to filter the list of users based on their current license, and another function to filter based on the last access date.

function Where-License()
{
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline)]
        $Users,

        [Parameter(Mandatory=$true)]
        [ValidateSet('Basic', 'Stakeholder')]
        [string]$license
    )

    BEGIN{}
    PROCESS
    {
        $users | Where-Object -FilterScript { $_.license -eq $license }
    }
    END{}
}

function Where-LicenseAccessed()
{
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline)]
        $Users,

        [Parameter()]
        [int]$WithinDays = 21,

        [Parameter()]
        [switch]$NotUsed = $false
    )
    BEGIN
    {
        $today = Get-Date
    }
    PROCESS
    {
        $Users | Where-Object -FilterScript {

            $lastAccess = (@( $_.lastAccessedDate, $_.dateCreated) | 
                            ForEach-Object { [datetime]$_ } |
                            Measure-Object -Maximum | Select-Object Maximum).Maximum

            $timespan = New-TimeSpan -Start $lastAccess -End $today

            if (($NotUsed -and $timespan.Days -gt $WithinDays) -or ($NotUsed -eq $false -and $timespan.Days -le $WithinDays)) {
                Write-Host ("User {0} last accessed within {1} days." -f $_.email, $timespan.Days)
                return $true
            }

            return $false
        }
    }
    END {}
}

If you're new to PowerShell, the BEGIN,PROCESS,END blocks may look peculiar but they are essential for chaining results of arrays together. There’s a really good write-up on this here. But to demonstrate, we can now chain these methods together, like so:

Get-AzureDevOpsUsers | 
    Where-License -license Basic | 
        Where-LicenseAccessed -NotUsed -WithinDays 21

Revoking Licenses

And then we use the ever so important function to revoke licenses. This is simply a wrapper around the az devops command to improve readability.

function Set-AzureDevOpsLicense()
{
    param(
        [Parameter(Mandatory=$true)]
        $User,

        [Parameter(Mandatory=$true)]
        [ValidateSet('express','stakeholder')]
        [string]$license
    )
    Write-Host ("Setting User {0} license to {1}" -f $_.email, $license)
    az devops user update --user $user.email --license-type $license | ConvertFrom-Json
}

Putting it all Together

So we've written all these nice little functions, let's put them together into a little PowerShell haiku:

$users = Get-AzureDevOpsUsers | 
                 Where-License -license Basic | 
                 Where-LicenseAccessed -NotUsed -WithinDays 21 | 
                 ForEach-Object { Set-AzureDevOpsLicense -User $_ -license stakeholder }
Write-Host ("Changed {0} licenses." -f $users.length)

Nice. Az you can see that wasn't hard at all, and it probably saved my boss a few bucks. There's a few ways we can make this better...

Dealing with lots of Users

The az devops user list command can return up to 1000 users but only returns 100 by default. If you have a large organization, you'll need to make a few round trips to get all the data you need.

Let’s modify our Get-AzureDevOpsUsers function to retrieve all the users in the organization in batches.

function Get-AzureDevOpsUsers() {

    param(
        [Parameter()]
        [int]$BatchSize = 100
    )

    $query = "items[].{license:accessLevel.licenseDisplayName, email:user.principalName, dateCreated:dateCreated, lastAccessedDate:lastAccessedDate }"
    
    $users = @()

    $totalCount = az devops user list --query "totalCount"
    Write-Host "Fetching $totalCount users" -NoNewline

    $intervals = [math]::Ceiling($totalCount / $BatchSize)

    for($i = 0; $i -lt $intervals; $i++) {

        Write-Host -NoNewline "."

        $skip = $i * $BatchSize;
        $results = az devops user list --query "$query" --top $BatchSize --skip $skip | ConvertFrom-Json

        $users = $users + $results
    }   

    return $users
}

Giving licenses back

If the script can taketh licenses away, it should also giveth them back. To do this, we need the means to identify who should have a license. This can be accomplished using an Azure AD User Group populated with all users that should have licenses.

To get the list of these users, the Azure CLI comes to the rescue again. Also again, learning JMESPath really helps us because we can simplify the entire result into a basic string array:

az login --tenant <tenantid>
$licensedUsers = az ad group member list -g <groupname> --query "[].otherMails[0]" | ConvertFrom-Json

Note that I'm using the otherMails property to get the email address, your mileage may vary, but in my Azure AD, this setting matches Members and Guests with their email address in Azure DevOps.

With this magic array of users, my haiku can now reassign users their license if they've logged in recently without a license (sorry mate):

$licensedUsers = az ad group member list -g ADO_LicensedUsers --query "[].otherMails[0]" | ConvertFrom-Json

$users = Get-AzureDevOpsUsers
$reactivatedUsers = $user | Where-License -license Stakeholder | 
                            Where-LicenseAccessed -WithinDays 3 | 
                            Where-Object -FilterScript { $licensedUsers.Contains($_.email) } | 
                            ForEach-Object { Set-AzureDevOpsLicense -User $_ -license express }

$deactivatedUsers = $user | Where-License -license Basic | 
                            Where-LicenseAccessed -NotUsed -WithinDays 21 | 
                            ForEach-Object { Set-AzureDevOpsLicense -User $_ -license stakeholder }

Write-Host ("Reviewed {0} users" -f $users.Length)
Write-Host ("Deactivated {0} licenses." -f $deactivatedUsers.length)
Write-Host ("Reactivated {0} licenses." -f $reactivatedUsers.length)

Wrapping up

In the last few posts, we've looked at the Azure DevOps CLI, understanding JMESPath and now integrating both into PowerShell to unleash some awesome. If you're interested in the source code for this post, you can find it here.

In my next post, we'll build upon this and show you how to integrate this script magic into an Azure Pipeline that runs on a schedule.

Happy coding.

0 comments: