May 16, 2025

Manage Azure DevOps Licences with Entra ID Access Packages — Part 2

Auto-renew seats for real contributors, downgrade everything else.

Quick recap of Part 1:
• Each paid tier (Basic & Basic + Test Plans) now lives inside a 30-day Access Package.
• Group rules in Azure DevOps grant the licence the moment a package is approved.

Today we’ll build Function #1 — Manage Licences, which runs every four hours and follows the logic in the diagram:

flowchart TD
    Start([Timer every 4 h]) --> GetUsers[For each paid-licence user]
    GetUsers --> Direct{Licence<br>directly<br>assigned?}
    Direct -- Yes --> Except{User in<br>exceptions list?}
    Except -- Yes --> End1(End)
    Except -- No --> Downgrade[Downgrade licence<br>to *Stakeholder*] --> End2(End)
    Direct -- No --> Active{User logged in<br>last 15 days?}
    Active -- No --> End3(End)
    Active -- Yes --> Expiring{Access-package<br>expires ≤ 17 days?}
    Expiring -- No --> End4(End)
    Expiring -- Yes --> Extend[Extend expiry<br>+30 days] --> End5(End)

Why 17 days? Microsoft schedules the first “your access ends in 14 days” email 15 days before, so a 17-day threshold guarantees active users never see it.

Directly assigned licenses and exception lists? Generally, you don’t want to assign licenses directly to your users. However, you might have a number of special users, that need directly assigned licenses. For them, you can have an exeption list. We will have logic in our automation which will downgrade directly assigned licenses to Stakeholder, unless they are in the exceptions list.

Authenticate with Azure DevOps and Fetch User Entitlements

param($Timer, $TriggerMetadata)

$OrganizationName = $env:OrganizationName
Write-Host "Obtaining Azure DevOps Access Token"
try {
    $bearer_token = (Invoke-RestMethod -Method Get -Headers @{"X-IDENTITY-HEADER"="$env:IDENTITY_HEADER"} -Uri "$( $env:IDENTITY_ENDPOINT )?resource=499b84ac-1321-427f-aa17-267ca6975798&api-version=2019-08-01&client_id=$( $env:AZURE_CLIENT_ID )").access_token
} catch {
    throw "Could not authenticate with Azure DevOps service. Error message: $($_.Exception.Message)"
}

$adoAuthHeader = @{Authorization = 'Bearer ' + $bearer_token}
$adoApiPrefix = "https://vsaex.dev.azure.com/" + $OrganizationName

Write-Host "Fetching list of user entitlements from Azure DevOps"
try {
    $ProgressPreference = 'SilentlyContinue' # disable progress bar since it slows down the script
    $paidLicenseUsers = @()
    $continuationToken = ''
    Do {
        $result = (Invoke-WebRequest -Uri "$adoApiPrefix/_apis/userentitlements?api-version=7.2-preview.4&continuationToken=$continuationToken" -Method Get -Headers $adoAuthHeader)
        $allusers = ($result.Content | ConvertFrom-Json).items
        $paidLicenseUsers += $allusers | Where-Object {$_.accesslevel.accountLicenseType -ne 'stakeholder'}
        $continuationToken = ($result.Content | Convertfrom-json).continuationToken
        $continuationToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) 
    } While ($continuationToken)
} catch {
    throw "Could not fetch user entitlements. Error message: $($_.Exception.Message)"
}

param($Timer, $TriggerMetadata) is needed for Azure Functions with Timer trigger.

$env:OrganizationName is how we reference the Azure DevOps organization name, which we store in Azure Function Environment variables.

$env:IDENTITY_HEADER returns the value of the IDENTITY_HEADER environment variable. This header is used to help mitigate SSRF attacks.

Note the continuationToken parameter. We are fetching user entitlements in a loop. The API splits the results into pages and if there is a next page, it returns a continuation token, which can be used to fetch the next page.

$env:IDENTITY_ENDPOINT returns the URL to the local token service.

$env:AZURE_CLIENT_ID is the client ID of the user-assigned identity to be used. We are using a user-assigned managed identity for our Azure Function App. This managed identity should have the following permissions:

  • Project Collections Administrator role in our Azure DevOps organization,
  • EntitlementManagement.ReadWrite.All Microsoft Graph API Permission

See https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Cpowershell#rest-endpoint-reference for details of above components.

Downgrade Directly Assigned Paid Licenses

# Basic and Basic + Test Plans licenses that are directly assigned (ignoring other paid licenses such as Visual Studio Enterprise):
$allDirectAssignmentUsers = $paidLicenseUsers | Where-Object {$_.accesslevel.assignmentSource -eq 'unknown' -and ($_.accessLevel.licenseDisplayName -eq 'Basic' -or $_.accessLevel.licenseDisplayName -eq 'Basic + Test Plans')}
$directAssignmentAllowedUsers = $env:DirectAssignmentAllowedUsers -split(',') | ForEach-Object { $_.Trim() }
$directAssignmentUsersToProcess = $allDirectAssignmentUsers | Where-Object {$_.user.principalName -notin $directAssignmentAllowedUsers}

Write-Host "Number of users with a directly assigned license: $($allDirectAssignmentUsers.count)"
Write-Host "Number of users that are allowed to have a directly assigned license: $($directAssignmentAllowedUsers.count)"
Write-Host "Number of users whose direct assignments will be downgraded to Stakeholder: $($directAssignmentUsersToProcess.count)"

foreach ($user in $directAssignmentUsersToProcess) {
    # downgrade access level to Stakeholder
    $body = @(
                @{
                    from  = ""
                    op    = "replace"
                    path  = "/accessLevel"
                    value = @{
                        accountLicenseType = "Stakeholder"
                        licensingSource    = "account"
                    }
                }
    )
    $bodyJson = Convertto-Json $body
    try {
        write-host "Downgrading Azure DevOps license of $($user.user.principalName) to Stakeholder"
        Invoke-WebRequest -uri  "$adoApiPrefix/_apis/userentitlements/$($user.id)?api-version=5.1-preview.2" -Method PATCH -Headers $adoAuthHeader -Body $bodyjson -ContentType "application/json-patch+json" -ErrorAction 'Stop'
    } catch {
        throw "Azure DevOps license downgrade operation unsuccessful. Details: $($_.Exception.Message)"
    }
}

if (-not $directAssignmentUsersToProcess) {
    Write-Host "No directly assigned AzureDevOps licenses need downgrading."
}

Note that we are still using the User Entitlements API, this time with the PATCH method. We are downgrading each directly assigned ($_.accesslevel.assignmentSource -eq 'unknown') paid ($_.accessLevel.licenseDisplayName -eq 'Basic' -or $_.accessLevel.licenseDisplayName -eq 'Basic + Test Plans') license to Stakeholder.

Update-AccessPackageAssignments Function

Now let’s write a function that will update the access package assignments according to the logic we already discussed above.

function Update-AccessPackageAssignments { 
    param ($userEntitlements, $paidLicenseAccessPackageAssignments, $licenseType)
    Write-Host "Now processing $licenseType license access package assignments..."
    $processedUsers = @()
    foreach ($user in $userEntitlements) {
        # if the user has an access package assignment:
        if ($user.user.originId -in $paidLicenseAccessPackageAssignments.target.objectId) {
            # if the user has been active within the last 15 days:
            if ($user.lastAccessedDate -ge (get-date).AddDays(-15)) {
                $accessPackageAssignment = $paidLicenseAccessPackageAssignments | Where-Object {$_.target.objectId -eq $user.user.originId }
                # if the user's access package assignment is expiring in less than 17 days:
                # 17 days was selected because a notification e-mail is sent to the user 15 days before the expiration.
                # we do not want the user to receive this e-mail if they are active to avoid confusion.
                if ($accessPackageAssignment.schedule.expiration.endDateTime -le (get-date).AddDays(17)) {
                    Write-Host "Extending access package assignment for $($user.user.principalName)..."
                    Write-Host "User's last access date is: $($user.lastAccessedDate)"
                    Write-Host "User's access package expires on: $($accessPackageAssignment.schedule.expiration.endDateTime)"
                    Write-Host "Today's date is $(get-date)"
                    $processedUsers += $user.user.principalName
                    $now = Get-Date
                    $body = @{
                        "@odata.type" = "#microsoft.graph.accessPackageAssignmentRequest"
                        requestType = "adminUpdate"
                        schedule = @{
                            "@odata.type" = "microsoft.graph.entitlementManagementSchedule"
                            startDateTime = $now
                        }
                        assignment = @{
                            id = $accessPackageAssignment.id
                        }
                    }
                    try {
                        New-MgEntitlementManagementAssignmentRequest -BodyParameter $body -ErrorAction 'Stop'
                        

                    } catch {
                        if ($_.Exception.Message -match 'There is already an existing open request') {
                            Write-Host 'There is already an existing open request. Nothing to do.'
                        } else {

                            throw "Could not extend access package expiration. Error occurred: $($_.Exception.Message)"
                        }
                    }
                }
            }
        }
    }
    if (-not $processedUsers) {
        Write-Host "No access package assignments need extending for $licenseType license."

    }
}

From now on, we are only interested in users that received their licenses from group rules ($_.accesslevel.assignmentSource -eq 'groupRule'). So, lets filter other out and split them into two variables: one for the users with Basic and the other for users with Basic + Test Plans licenses:

$groupRuleBasicUsers = $paidLicenseUsers | Where-Object {$_.accesslevel.licenseDisplayName -eq 'Basic' -and $_.accesslevel.assignmentSource -eq 'groupRule'}
$groupRuleTestPlansUsers = $paidLicenseUsers | Where-Object {$_.accesslevel.licenseDisplayName -eq 'Basic + Test Plans' -and $_.accesslevel.assignmentSource -eq 'groupRule'}

Connect to Microsoft Graph API and Fetch Access Package Assignments

try {
    Write-Host "Connecting to Microsoft Graph..."
    Connect-MgGraph -Identity -ClientId $env:AZURE_CLIENT_ID -NoWelcome -ErrorAction 'Stop'
} Catch {
    throw "Could not connect to Microsoft Graph. Error message: $($_.Exception.Message)"
}

try {
    Write-Host "Obtaining Access Package assignments for Basic license..."
    $basicLicenseAccessPackageAssignments = @()
    $nextPageUrl = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignments?expand=target&filter=accessPackage/id eq '$($env:DeveloperLicenseAccessPackageId)' and state eq 'delivered'"
    Do {
        $result = Invoke-MgGraphRequest -Method GET -Uri $nextPageUrl -ErrorAction 'Stop'
        $basicLicenseAccessPackageAssignments += $result.value
        $nextPageUrl = $result.'@odata.nextLink'
    } While ($nextPageUrl)
    
    Write-Host "Obtaining Access Package assignments for Basic + Test Plans license..."
    $testPlansLicenseAccessPackageAssignments =@()
    $nextPageUrl = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignments?expand=target&filter=accessPackage/id eq '$($env:TesterLicenseAccessPackageId)' and state eq 'delivered'"
    Do {
        $result = Invoke-MgGraphRequest -Method GET -Uri $nextPageUrl -ErrorAction 'Stop'
        $testPlansLicenseAccessPackageAssignments += $result.value
        $nextPageUrl = $result.'@odata.nextLink'
    } While ($nextPageUrl)
} catch {

    throw "Could not get access package assignments from Microsoft Graph API. Error message: $($_.Exception.Message)" 
}

Note $env:TesterLicenseAccessPackageId and $env:DeveloperLicenseAccessPackageId environment variables. We are storing our access package IDs in these.

Extend Access Package Assignmets

Finally, let’s use the function we wrote to extend access package assignments by calling it twice (once for each license type/access package).

Write-Host "Extending access package assignments for users..."

Update-AccessPackageAssignments -groupRuleUsers $groupRuleBasicUsers `
                                -paidLicenseAccessPackageAssignments $basicLicenseAccessPackageAssignments `
                                -licenseType "Basic"

Update-AccessPackageAssignments -groupRuleUsers $groupRuleTestPlansUsers `
                                -paidLicenseAccessPackageAssignments $testPlansLicenseAccessPackageAssignments `
                                -licenseType "Basic + Test Plans"