Azure DevOps - How to collect all accounts from Organization using PowerShell

Steel Contributor

When you are managing an Azure DevOps instance, you can have one or many Organizations (depending of your isolation and distribution requirements).

Each organization will have a specific Account list (with license associated), but there is no automatic cleanup to remove disable accounts or consolidate all account in one view, and the built-in CSV Export module is limited in usage.

 

This PowerShell Script will connect to all organization you specified in setting (using a dedicated Token you have to use or dedicated per Organization) to get all declared accounts, and loop for each account the Project part of. If No project assigned, you will have "NO PROJECT" as value.

 

 

 

Import-module AzureAD
connect-azureAD

#region  --  Customized Script Settings (need to be reviewed) ---
# ================== / Parameters to Adapt \ ================== 
[string]$SharePointTeamSite = "https://tenant.sharepoint.com/sites/YourCollection/"
[string]$DocumentLibrary = "YourDocLib"
[string]$GlobalPATForAllORganizations= "xxxyyyyxxxxxxyyyyy" #If you have a global PAT
[string]$JSonFolderPath = "C:\JSONDEVOPS"
[string]$DomainNameEmail = "@emaildomain.com"

# ================== \ Parameters to Adapt / ================== 
#endregion

#region --  Internal Script Settings  ---

$OrganizationList = @()
[string]$PAT = ""
[string]$OrganizationName = ""
[string]$Organization = ""
[string]$UserGroupJSonFilePath = ""
[string]$UserListJSonFilePath = ""
[string]$AccountValidInAzureAD = ""
$DataRefreshDate = Get-Date -Format "yyyy-MM-dd"
[string]$TempEmailAddress = ""
$UserGroupsOveralOrganization = @()
$UserGroupOveralOragnizationJSonFilePath = "$JSonFolderPath\DevOps-UserGroups.json"

#endregion

#region --  Each Az DevOps Organization  --

#YourOrganization1
$PAT = $GlobalPATForAllORganizations #If specific https://dev.azure.com/YourOrganization1/_usersSettings/tokens
$OrganizationName = "YourOrganization1"
$OrganizationWithPAT = New-Object PSObject -property @{OrganizationName=$OrganizationName;OrganizationPAT=$PAT}
$OrganizationList += $OrganizationWithPAT

#YourOrganization2
$PAT = $GlobalPATForAllORganizations #If specific https://dev.azure.com/YourOrganization2/_usersSettings/tokens
$OrganizationName = "YourOrganization2"
$OrganizationWithPAT = New-Object PSObject -property @{OrganizationName=$OrganizationName;OrganizationPAT=$PAT}
$OrganizationList += $OrganizationWithPAT

#YourOrganization3
$PAT = $GlobalPATForAllORganizations #If specific https://dev.azure.com/YourOrganization3/_usersSettings/tokens
$OrganizationName = "YourOrganization3"
$OrganizationWithPAT = New-Object PSObject -property @{OrganizationName=$OrganizationName;OrganizationPAT=$PAT}
$OrganizationList += $OrganizationWithPAT

#endregion

foreach($MyOrganization in $OrganizationList)
{
	$Organization = "https://dev.azure.com/$($MyOrganization.OrganizationName)/" 
	$UserGroupsPerOrganization = @()
	$UserGroupJSonFilePath = "$JSonFolderPath\$($MyOrganization.OrganizationName)-UserGroups.json"
	$UserListJSonFilePath = "$JSonFolderPath\$($MyOrganization.OrganizationName)-UsersList.json"
	echo $($MyOrganization.OrganizationPAT) | az devops login --org $Organization
	az devops configure --defaults organization=$Organization
	$allUsers = az devops user list --org $Organization --top 10000 | ConvertFrom-Json
	#write-host "All Users:", $allUsers
	$allUsers | ConvertTo-Json | Out-File -FilePath $UserListJSonFilePath
	foreach($au in $allUsers.members)
	{
		if($au.user.mailAddress.endswith($DomainNameEmail))
		{
			$TempEmailAddress = $au.user.mailAddress -replace "'", "''"
			
			$MyAzureADUser = Get-AzureADUser -Filter "userPrincipalName eq '$($TempEmailAddress)'"
			if($MyAzureADUser)
			{
				if($MyAzureADUser.AccountEnabled)
				{
					$AccountValidInAzureAD = "ACCOUNT VALID IN AZURE AD"
				}
				else
				{
					$AccountValidInAzureAD = "ACCOUNT DISABLE IN AZURE AD"
				}
			}
			else
			{
				$AccountValidInAzureAD = "ACCOUNT NOT EXIST IN AZURE AD"
			}
			
		}
		else
		{
			$AccountValidInAzureAD = "ACCOUNT NOT INTERNAL EMAIL"
		}

		$activeUserGroups = az devops security group membership list --id $au.user.principalName --org $Organization --relationship memberof | ConvertFrom-Json
		[array]$groups = ($activeUserGroups | Get-Member -MemberType NoteProperty).Name
		if ($groups.count -gt 0)
		{
			foreach ($aug in $groups)
			{
				$UserGroupsPerOrganization += New-Object -TypeName PSObject -Property @{
													DevOpsOrganizationName=$MyOrganization.OrganizationName
													principalName=$au.user.principalName
													displayName=$au.user.displayName
													mailAddress=$au.user.mailAddress
													UserID=$au.id
													AccountValidInAzureAD = $AccountValidInAzureAD
													LicenseType=$au.accessLevel.licenseDisplayName
													dateCreated=$au.dateCreated
													lastAccessedDate=$au.lastAccessedDate
													ProjectName= $($activeUserGroups.$aug.principalName.Split("\\"))[0]
													GroupName=$activeUserGroups.$aug.principalName
													DataRefreshDate = $DataRefreshDate
													}
			}
		}
		else
		{
				$UserGroupsPerOrganization += New-Object -TypeName PSObject -Property @{
													DevOpsOrganizationName=$MyOrganization.OrganizationName
													principalName=$au.user.principalName
													displayName=$au.user.displayName
													mailAddress=$au.user.mailAddress
													UserID=$au.id
													AccountValidInAzureAD = $AccountValidInAzureAD
													LicenseType=$au.accessLevel.licenseDisplayName
													dateCreated=$au.dateCreated
													lastAccessedDate=$au.lastAccessedDate
													ProjectName= "[NO PROJECT]"
													GroupName="[NO PROJECT GROUP]"
													DataRefreshDate = $DataRefreshDate
													}
		}
	}

	$UserGroupsPerOrganization | ConvertTo-Json | Out-File -FilePath $UserGroupJSonFilePath

	$UserGroupsOveralOrganization += $UserGroupsPerOrganization 
}

$UserGroupsOveralOrganization | ConvertTo-Json | Out-File -FilePath $UserGroupOveralOragnizationJSonFilePath

Import-Module PnP.PowerShell -DisableNameChecking
Connect-PnPOnline -Url $SharePointTeamSite -UseWebLogin

$Files = Get-ChildItem -Path $JSonFolderPath -Force -Recurse
#Upload All files from the directory
ForEach ($File in $Files)
{
    Write-host "Uploading $($File.Directory)\$($File.Name)"
    #upload a file to sharepoint online using powershell - Upload File and Set Metadata
    Add-PnPFile -Path "$($File.Directory)\$($File.Name)" -Folder $DocumentLibrary -Values @{"Title" = $($File.Name)}
}

 

 

 

The result will be a JSON file (saved in SharePoint), you can easily load in Power Bi to build your dedicated Report helping you to adapt Develop license with real usage like:

  • Remove all accounts Disable in Azure AD
  • Downgrade all account license from Basic+ to Stakeholder if no connection since more than 1 year
  • Remove all accounts without any project
  • Any other rules part of your Governance model

That could also be adapted to use an Azure Engine to cleanup automatically your Organization.

In any case, that part need to be integrated into your FinOps process to optimize your license costs as you really need and use. 

Fabrice Romelard

References used to build that script:

 

Update:

  • Script cleaned to be easily modified and used
3 Replies

@vinijmoura 

Welcome, it was really helpful to build that script

Fab