Forum Discussion
Azure DevOps - Send an email to each Project Administrator with Account listed into using PowerShell
We saw with a previous script how have a consolidated view of DevOps usage per organization:
That solution associated with Power Bi report is giving a good overall view of your IT delivery team and licenses.
But because the project management and user permission is under Project Manager umbrella, this new script is looking per organization and per project to send an email to Azure DevOps Project managers listing all accounts (with group, licenses, last connection, etc.) existing into their project.
Based on that script, you can better review permission and license management accorded with project owners, and match with real needs. This is also a security basic reviewing the external access (Partner, Suppliers, Customers, etc.).
Import-module AzureAD
connect-azureAD
#region -- Customized Script Settings (need to be reviewed) ---
# ================== / Parameters to Adapt \ ==================
[string]$GlobalPATForAllORganizations= "yyxxxxyyyyyxxxxxyyyy"
[string]$JSonFolderPath = "C:\DEVOPS_PROJECTS"
[string]$DomainNameEmail = "@yourdomain.com"
# -- Email configuration ----
[string]$DevOpsAdmins = "AdminEmail@yourdomain.com"
[string]$SMTPServer = "your.smtp.yourdomain.com"
[string]$EmailCoreTitle = "[[yourproject]] - Review Azure DevOps Accounts and licenses associated"
[string]$EmailPreCoreContent = "<DIV><P>Dear team,<BR><BR>
We are reviewing all Azure DevOps accounts associated with your project <B>[[yourproject]]</B>.<BR>
You can find an extract of all accounts listed for your project, and we need your control on each of those.<BR><BR>
Please to check each account and confirm if we have to:<BR>
<UL>
<LI>Remove the account from your project</LI>
<LI>Downgrade the license level, from Basic or Visual Studio (license paid) to Stakeholder (free of charge restricted to Work Items) - <a href='https://docs.microsoft.com/en-us/azure/devops/organizations/security/access-levels?view=azure-devops#supported-access-levels'>More details</a></LI>
</UL>
<BR>
Send that reviewed list to this email address '<B>$($DevOpsAdmins)</B>' and the change will be applied.<BR><BR>
---- ACCOUNT INTO YOUR DEVOPS PROJECT: [[yourproject]] ------</P></DIV>"
[string]$EmailPostCoreContent = "<DIV><P>-------------------------------------------------------------<BR><BR>
Thanks for your help.<BR>
Best Regards<BR><BR>
DevOps Administrator</P></DIV>"
# ================== \ Parameters to Adapt / ==================
#endregion
#region -- Internal Script Settings ---
$OrganizationList = @()
$ProjectAdministrators = @()
[string]$PAT = ""
[string]$OrganizationName = ""
[string]$Organization = ""
[string]$SecurityGroupsJSonFilePath = ""
[string]$UserGroupJSonFilePath = ""
[string]$GroupsListJSonFilePath = ""
[string]$AccountValidInAzureAD = ""
$DataRefreshDate = Get-Date -Format "yyyy-MM-dd"
[int]$TotalProjectPerOrganization = 0
[int]$ProjectInProgress = 0
# -- Email configuration ----
[string]$TempEmailAddress = ""
[string]$ProjectOwnerEmailAddresses = ""
[string]$ListToPutInMail = ""
[string]$EmailCoreTitleToUse = ""
[string]$EmailPreCoreContentToUse = ""
[string]$HTMLHeader = @"
<style>
TABLE {border-width: 1px; border-style: solid; border-color: black; border-collapse: collapse;}
TH {border-width: 1px; padding: 3px; border-style: solid; border-color: black; background-color: #FF6600;}
TD {border-width: 1px; padding: 3px; border-style: solid; border-color: black;}
</style>
"@
#endregion
#region -- Each Az DevOps Organization --
#YourOrganization1
$PAT = $GlobalPATForAllORganizations #https://dev.azure.com/YourOrganization1/_usersSettings/tokens
$OrganizationName = "YourOrganization1"
$OrganizationWithPAT = New-Object PSObject -property @{OrganizationName=$OrganizationName;OrganizationPAT=$PAT}
$OrganizationList += $OrganizationWithPAT
#YourOrganization2
$PAT = $GlobalPATForAllORganizations #https://dev.azure.com/YourOrganization2/_usersSettings/tokens
$OrganizationName = "YourOrganization2"
$OrganizationWithPAT = New-Object PSObject -property @{OrganizationName=$OrganizationName;OrganizationPAT=$PAT}
$OrganizationList += $OrganizationWithPAT
#YourOrganization3
$PAT = $GlobalPATForAllORganizations #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)
{
write-host " --------------------------------------------------------------------" -ForegroundColor White -BackgroundColor DarkYellow
write-host " ----- Organization :", $MyOrganization.OrganizationName ," ------" -ForegroundColor White -BackgroundColor DarkYellow
write-host " --------------------------------------------------------------------" -ForegroundColor White -BackgroundColor DarkYellow
$TotalProjectPerOrganization = 0
$ProjectInProgress = 0
$Organization = "https://dev.azure.com/$($MyOrganization.OrganizationName)/"
$UserGroupJSonFilePath = "$JSonFolderPath\$($MyOrganization.OrganizationName)-UserGroups.json"
$GroupsListJSonFilePath = "$JSonFolderPath\$($MyOrganization.OrganizationName)-AllProjects-List.json"
$SecurityGroupsJSonFilePath = "$JSonFolderPath\$($MyOrganization.OrganizationName)-SecurityGroups-List.json"
echo $($MyOrganization.OrganizationPAT) | az devops login --org $Organization
az devops configure --defaults organization=$Organization
#$allProjects = az devops project list --org $Organization --top 1 | ConvertFrom-Json | Select-Object -ExpandProperty value | Sort-Object name #Select 1st project to validate script
#$allProjects = az devops project list --org $Organization --top 10000 | ConvertFrom-Json | Select-Object -ExpandProperty value | Where-Object name -eq "YourProjectname" | Sort-Object name #Select filtered project
$allProjects = az devops project list --org $Organization --top 10000 | ConvertFrom-Json | Select-Object -ExpandProperty value | Sort-Object name #Select all projects
#$allProjects
#$allProjects | ConvertTo-Json | Out-File -FilePath $GroupsListJSonFilePath -Encoding UTF8 #If you want to save a copy
$TotalProjectPerOrganization = $allProjects.Count
foreach($myProject in $allProjects)
{
$ProjectInProgress += 1
$ProjectAdministrators = @()
$EmailPreCoreContentToUse = ""
$EmailCoreTitleToUse = ""
$ProjectOwnerEmailAddresses = ""
$ListToPutInMail = ""
$UserGroupsPerOrganizationAndProject = @()
write-host " ---------------------------------------------------------------------------------------------------- " -ForegroundColor Yellow
write-host " -- Project Name:", $myProject.Name, "[", $ProjectInProgress, " of ", $TotalProjectPerOrganization, "] --" -ForegroundColor Yellow
#write-host " > Project Description:", $myProject.Description -ForegroundColor DarkYellow
write-host " ---------------------------------------------------------------------------------------------------- " -ForegroundColor Yellow
$ProjectSecurityGroups = az devops security group list --org $Organization --project $myProject.Name | ConvertFrom-Json
#$ProjectSecurityGroups | Out-File -FilePath $SecurityGroupsJSonFilePath #-Encoding UTF8 #If you want to save a copy
foreach($mySecurityGroup in $ProjectSecurityGroups.graphGroups)
{
write-host " - Security Group Name:", $mySecurityGroup.displayName, "- Descriptor:", $mySecurityGroup.descriptor -ForegroundColor Magenta
#write-host " > Security Group Description:", $mySecurityGroup.Description -ForegroundColor DarkMagenta
$AllGroupMembers = az devops security group membership list --id $mySecurityGroup.descriptor --org $Organization --relationship members | ConvertFrom-Json
#write-host " JSON:", $AllGroupMembers
#$AllGroupMembers | ConvertTo-Json | Out-File -FilePath $UserGroupJSonFilePath -Encoding UTF8 #If you want to save a copy
[array]$groupMembers = ($AllGroupMembers | Get-Member -MemberType NoteProperty).Name
foreach($MyUserInGroup in $groupMembers)
{
if($AllGroupMembers.$MyUserInGroup.mailAddress -ne $null)
{
$AccountValidInAzureAD = ""
write-host " ==> User Name:", $AllGroupMembers.$MyUserInGroup.displayName, "- Email:", $AllGroupMembers.$MyUserInGroup.mailAddress -ForegroundColor Green
if($AllGroupMembers.$MyUserInGroup.mailAddress.endswith($DomainNameEmail))
{
$TempEmailAddress = $AllGroupMembers.$MyUserInGroup.mailAddress -replace "'", "''"
$MyAzureADUser = Get-AzureADUser -Filter "userPrincipalName eq '$($TempEmailAddress)'"
if($MyAzureADUser)
{
if($MyAzureADUser.AccountEnabled)
{
$AccountValidInAzureAD = "ACCOUNT VALID IN AZURE ACTIVE DIRECTORY"
}
else
{
$AccountValidInAzureAD = "ACCOUNT DISABLE IN AZURE ACTIVE DIRECTORY"
}
}
else
{
$AccountValidInAzureAD = "ACCOUNT NOT EXIST IN AZURE ACTIVE DIRECTORY"
}
}
else
{
$AccountValidInAzureAD = "ACCOUNT NOT INTERNAL EMAIL ADDRESS"
}
$MyUserDetails = az devops user show --user $AllGroupMembers.$MyUserInGroup.mailAddress --org $Organization | ConvertFrom-Json
#write-host " JSON:", $MyUserDetails
$UserGroupsPerOrganizationAndProject += New-Object -TypeName PSObject -Property @{
UserName=$AllGroupMembers.$MyUserInGroup.displayName
mailAddress=$AllGroupMembers.$MyUserInGroup.mailAddress
AccountStatusInAzureAD = $AccountValidInAzureAD
LicenseType=$MyUserDetails.accessLevel.licenseDisplayName
dateCreated=$($MyUserDetails.dateCreated).Substring(0, 10)
lastAccessedDate=$($MyUserDetails.lastAccessedDate).Substring(0, 10)
ProjectName= $myProject.Name
GroupName=$mySecurityGroup.displayName
DataRefreshDate = $DataRefreshDate
}
}
}
}
$EmailCoreTitleToUse = $EmailCoreTitle.replace("[[yourproject]]", $myProject.Name)
$ProjectAdministrators = $UserGroupsPerOrganizationAndProject | where { $_.GroupName -eq "Project Administrators" } | Select mailAddress
if($ProjectAdministrators.count -gt 0)
{
foreach ($myProjectAdminEMail in $ProjectAdministrators)
{
if ($myProjectAdminEMail.mailAddress.endswith($DomainNameEmail))
{
$ProjectOwnerEmailAddresses += $myProjectAdminEMail.MailAddress
$ProjectOwnerEmailAddresses += ";"
}
}
if($ProjectOwnerEmailAddresses -eq "")
{
$ProjectOwnerEmailAddresses = $DevOpsAdmins
$EmailCoreTitleToUse = "NO INTERNAL PROJECT ADMIN - " + $EmailCoreTitleToUse
}
}
else
{
$ProjectOwnerEmailAddresses = $DevOpsAdmins
$EmailCoreTitleToUse = "NO PROJECT ADMIN - " + $EmailCoreTitleToUse
}
$UserGroupsPerOrganizationAndProject | ConvertTo-Json
#write-host " JSON:", $UserGroupsPerOrganizationAndProject
$EmailPreCoreContentToUse = $EmailPreCoreContent.replace("[[yourproject]]", $myProject.Name)
#$ListToPutInMail = $UserGroupsPerOrganizationAndProject | Format-Table UserName, mailAddress, AccountStatusInAzureAD, LicenseType, GroupName, dateCreated, lastAccessedDate | Out-String
$ListToPutInMail = $UserGroupsPerOrganizationAndProject | ConvertTo-Html -Property UserName, mailAddress, AccountStatusInAzureAD, LicenseType, GroupName, dateCreated, lastAccessedDate -PreContent $EmailPreCoreContentToUse -PostContent $EmailPostCoreContent -Head $HTMLHeader | Out-String
#write-host " HTML Table:", $ListToPutInMail
#write-host " Project Admins:", $ProjectAdministrators
$ProjectOwnerEmailAddresses = $ProjectOwnerEmailAddresses.Trim()
if($ProjectOwnerEmailAddresses.EndsWith(";"))
{
write-host " Remove last char from:", $ProjectOwnerEmailAddresses
$ProjectOwnerEmailAddresses = $ProjectOwnerEmailAddresses.Substring(0, $ProjectOwnerEmailAddresses.Length-1)
write-host " Last char removed from:", $ProjectOwnerEmailAddresses
}
write-host " Project Admin Emails:", $ProjectOwnerEmailAddresses
write-host " *> Email Title:", $EmailCoreTitleToUse
Send-MailMessage -To $ProjectOwnerEmailAddresses.split(';') -From $DevOpsAdmins -Cc $DevOpsAdmins -Subject $EmailCoreTitleToUse -Body $ListToPutInMail -BodyAsHtml -SmtpServer $SMTPServer
write-host " ---------------------------------------------------------------------------------------------------- " -ForegroundColor Yellow
}
write-host " --------------------------------------------------------------------" -ForegroundColor White -BackgroundColor DarkYellow
}
You have a restricted list of settings to adapt and run the script.
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: