Azure DevOps - Send an email to each Project Administrator with Account listed into using PowerShell

%3CLINGO-SUB%20id%3D%22lingo-sub-2193427%22%20slang%3D%22en-US%22%3EAzure%20DevOps%20-%20Send%20an%20email%20to%20each%20Project%20Administrator%20with%20Account%20listed%20into%20using%20PowerShell%3C%2FLINGO-SUB%3E%3CLINGO-BODY%20id%3D%22lingo-body-2193427%22%20slang%3D%22en-US%22%3E%3CP%3EWe%20saw%20with%20a%20previous%20script%20how%20have%20a%20consolidated%20view%20of%20DevOps%20usage%20per%20organization%3A%3C%2FP%3E%3CUL%3E%3CLI%3E%3CA%20title%3D%22Azure%20DevOps%20-%20How%20to%20collect%20all%20accounts%20from%20Organization%20using%20PowerShell%22%20href%3D%22https%3A%2F%2Ftechcommunity.microsoft.com%2Ft5%2Fazure-devops%2Fazure-devops-how-to-collect-all-accounts-from-organization-using%2Fm-p%2F2120850%22%20target%3D%22_blank%22%3E%3CSTRONG%3EAzure%20DevOps%20-%20How%20to%20collect%20all%20accounts%20from%20Organization%20using%20PowerShell%3C%2FSTRONG%3E%3C%2FA%3E%3CSTRONG%3E%26nbsp%3B%3C%2FSTRONG%3E%3C%2FLI%3E%3C%2FUL%3E%3CP%3EThat%20solution%20associated%20with%20Power%20Bi%20report%20is%20giving%20a%20good%20overall%20view%20of%20your%20IT%20delivery%20team%20and%20licenses.%3C%2FP%3E%3CP%3EBut%20because%20the%20project%20management%20and%20user%20permission%20is%20under%20Project%20Manager%20umbrella%2C%20this%20new%20script%20is%20looking%20per%20organization%20and%20per%20project%20to%20send%20an%20email%20to%20Azure%20DevOps%20Project%20managers%20listing%20all%20accounts%20(with%20group%2C%20licenses%2C%20last%20connection%2C%20etc.)%20existing%20into%20their%20project.%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3EBased%20on%20that%20script%2C%20you%20can%20better%20review%20permission%20and%20license%20management%20accorded%20with%20project%20owners%2C%20and%20match%20with%20real%20needs.%26nbsp%3BThis%20is%20also%20a%20security%20basic%20reviewing%20the%20external%20access%20(Partner%2C%20Suppliers%2C%20Customers%2C%20etc.).%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CPRE%20class%3D%22lia-code-sample%20language-powershell%22%3E%3CCODE%3EImport-module%20AzureAD%0Aconnect-azureAD%0A%0A%23region%20%20--%20%20Customized%20Script%20Settings%20(need%20to%20be%20reviewed)%20---%0A%23%20%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%20%2F%20Parameters%20to%20Adapt%20%5C%20%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%20%0A%5Bstring%5D%24GlobalPATForAllORganizations%3D%20%22yyxxxxyyyyyxxxxxyyyy%22%0A%5Bstring%5D%24JSonFolderPath%20%3D%20%22C%3A%5CDEVOPS_PROJECTS%22%0A%5Bstring%5D%24DomainNameEmail%20%3D%20%22%40yourdomain.com%22%0A%0A%23%20--%20Email%20configuration%20----%0A%5Bstring%5D%24DevOpsAdmins%20%3D%20%22AdminEmail%40yourdomain.com%22%0A%5Bstring%5D%24SMTPServer%20%3D%20%22your.smtp.yourdomain.com%22%0A%0A%5Bstring%5D%24EmailCoreTitle%20%3D%20%22%5B%5Byourproject%5D%5D%20-%20Review%20Azure%20DevOps%20Accounts%20and%20licenses%20associated%22%0A%0A%5Bstring%5D%24EmailPreCoreContent%20%3D%20%22%3CDIV%3E%3CP%3EDear%20team%2C%3CBR%20%2F%3E%3CBR%20%2F%3E%0AWe%20are%20reviewing%20all%20Azure%20DevOps%20accounts%20associated%20with%20your%20project%20%3CB%3E%5B%5Byourproject%5D%5D%3C%2FB%3E.%3CBR%20%2F%3E%0AYou%20can%20find%20an%20extract%20of%20all%20accounts%20listed%20for%20your%20project%2C%20and%20we%20need%20your%20control%20on%20each%20of%20those.%3CBR%20%2F%3E%3CBR%20%2F%3E%0A%0APlease%20to%20check%20each%20account%20and%20confirm%20if%20we%20have%20to%3A%3CBR%20%2F%3E%0A%3CUL%3E%0A%20%20%3CLI%3ERemove%20the%20account%20from%20your%20project%3C%2FLI%3E%0A%20%20%3CLI%3EDowngrade%20the%20license%20level%2C%20from%20Basic%20or%20Visual%20Studio%20(license%20paid)%20to%20Stakeholder%20(free%20of%20charge%20restricted%20to%20Work%20Items)%20-%20%3CA%20href%3D%22https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fazure%2Fdevops%2Forganizations%2Fsecurity%2Faccess-levels%3Fview%3Dazure-devops%23supported-access-levels%22%20target%3D%22_blank%22%20rel%3D%22noopener%20noreferrer%22%3EMore%20details%3C%2FA%3E%3C%2FLI%3E%0A%3C%2FUL%3E%0A%3CBR%20%2F%3E%0ASend%20that%20reviewed%20list%20to%20this%20email%20address%20'%3CB%3E%24(%24DevOpsAdmins)%3C%2FB%3E'%20and%20the%20change%20will%20be%20applied.%3CBR%20%2F%3E%3CBR%20%2F%3E%0A%0A----%20ACCOUNT%20INTO%20YOUR%20DEVOPS%20PROJECT%3A%20%5B%5Byourproject%5D%5D%20------%3C%2FP%3E%3C%2FDIV%3E%22%0A%0A%5Bstring%5D%24EmailPostCoreContent%20%3D%20%22%3CDIV%3E%3CP%3E-------------------------------------------------------------%3CBR%20%2F%3E%3CBR%20%2F%3E%0A%0AThanks%20for%20your%20help.%3CBR%20%2F%3E%0ABest%20Regards%3CBR%20%2F%3E%3CBR%20%2F%3E%0A%0ADevOps%20Administrator%3C%2FP%3E%3C%2FDIV%3E%22%0A%0A%23%20%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%20%5C%20Parameters%20to%20Adapt%20%2F%20%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%20%0A%23endregion%0A%0A%23region%20--%20%20Internal%20Script%20Settings%20%20---%0A%0A%24OrganizationList%20%3D%20%40()%0A%24ProjectAdministrators%20%3D%20%20%40()%0A%0A%5Bstring%5D%24PAT%20%3D%20%22%22%0A%5Bstring%5D%24OrganizationName%20%3D%20%22%22%0A%5Bstring%5D%24Organization%20%3D%20%22%22%0A%0A%5Bstring%5D%24SecurityGroupsJSonFilePath%20%3D%20%22%22%0A%5Bstring%5D%24UserGroupJSonFilePath%20%3D%20%22%22%0A%5Bstring%5D%24GroupsListJSonFilePath%20%3D%20%22%22%0A%5Bstring%5D%24AccountValidInAzureAD%20%3D%20%22%22%0A%24DataRefreshDate%20%3D%20Get-Date%20-Format%20%22yyyy-MM-dd%22%0A%5Bint%5D%24TotalProjectPerOrganization%20%3D%200%0A%5Bint%5D%24ProjectInProgress%20%3D%200%0A%0A%23%20--%20Email%20configuration%20----%0A%5Bstring%5D%24TempEmailAddress%20%3D%20%22%22%0A%5Bstring%5D%24ProjectOwnerEmailAddresses%20%3D%20%22%22%0A%5Bstring%5D%24ListToPutInMail%20%3D%20%22%22%0A%5Bstring%5D%24EmailCoreTitleToUse%20%3D%20%22%22%0A%5Bstring%5D%24EmailPreCoreContentToUse%20%3D%20%22%22%0A%0A%5Bstring%5D%24HTMLHeader%20%3D%20%40%22%0A%3CSTYLE%3E%0ATABLE%20%7Bborder-width%3A%201px%3B%20border-style%3A%20solid%3B%20border-color%3A%20black%3B%20border-collapse%3A%20collapse%3B%7D%0ATH%20%7Bborder-width%3A%201px%3B%20padding%3A%203px%3B%20border-style%3A%20solid%3B%20border-color%3A%20black%3B%20background-color%3A%20%23FF6600%3B%7D%0ATD%20%7Bborder-width%3A%201px%3B%20padding%3A%203px%3B%20border-style%3A%20solid%3B%20border-color%3A%20black%3B%7D%0A%3C%2FSTYLE%3E%0A%22%40%0A%0A%23endregion%0A%0A%23region%20--%20%20Each%20Az%20DevOps%20Organization%20%20--%0A%0A%23YourOrganization1%0A%24PAT%20%3D%20%24GlobalPATForAllORganizations%20%23https%3A%2F%2Fdev.azure.com%2FYourOrganization1%2F_usersSettings%2Ftokens%0A%24OrganizationName%20%3D%20%22YourOrganization1%22%0A%24OrganizationWithPAT%20%3D%20New-Object%20PSObject%20-property%20%40%7BOrganizationName%3D%24OrganizationName%3BOrganizationPAT%3D%24PAT%7D%0A%24OrganizationList%20%2B%3D%20%24OrganizationWithPAT%0A%0A%23YourOrganization2%0A%24PAT%20%3D%20%24GlobalPATForAllORganizations%20%23https%3A%2F%2Fdev.azure.com%2FYourOrganization2%2F_usersSettings%2Ftokens%0A%24OrganizationName%20%3D%20%22YourOrganization2%22%0A%24OrganizationWithPAT%20%3D%20New-Object%20PSObject%20-property%20%40%7BOrganizationName%3D%24OrganizationName%3BOrganizationPAT%3D%24PAT%7D%0A%24OrganizationList%20%2B%3D%20%24OrganizationWithPAT%0A%0A%23YourOrganization3%0A%24PAT%20%3D%20%24GlobalPATForAllORganizations%20%23https%3A%2F%2Fdev.azure.com%2FYourOrganization3%2F_usersSettings%2Ftokens%0A%24OrganizationName%20%3D%20%22YourOrganization3%22%0A%24OrganizationWithPAT%20%3D%20New-Object%20PSObject%20-property%20%40%7BOrganizationName%3D%24OrganizationName%3BOrganizationPAT%3D%24PAT%7D%0A%24OrganizationList%20%2B%3D%20%24OrganizationWithPAT%0A%0A%23endregion%0A%0Aforeach(%24MyOrganization%20in%20%24OrganizationList)%0A%7B%0A%20%20%20%20write-host%20%22%20--------------------------------------------------------------------%22%20-ForegroundColor%20White%20-BackgroundColor%20DarkYellow%0A%20%20%20%20write-host%20%22%20-----%20%20%20%20%20%20Organization%20%3A%22%2C%20%24MyOrganization.OrganizationName%20%2C%22%20%20%20%20%20%20%20%20%20%20%20%20%20%20------%22%20-ForegroundColor%20White%20-BackgroundColor%20DarkYellow%0A%20%20%20%20write-host%20%22%20--------------------------------------------------------------------%22%20-ForegroundColor%20White%20-BackgroundColor%20DarkYellow%0A%20%20%20%20%24TotalProjectPerOrganization%20%3D%200%0A%20%20%20%20%24ProjectInProgress%20%3D%200%0A%20%24Organization%20%3D%20%22https%3A%2F%2Fdev.azure.com%2F%24(%24MyOrganization.OrganizationName)%2F%22%20%0A%20%24UserGroupJSonFilePath%20%3D%20%22%24JSonFolderPath%5C%24(%24MyOrganization.OrganizationName)-UserGroups.json%22%0A%20%24GroupsListJSonFilePath%20%3D%20%22%24JSonFolderPath%5C%24(%24MyOrganization.OrganizationName)-AllProjects-List.json%22%0A%20%24SecurityGroupsJSonFilePath%20%3D%20%22%24JSonFolderPath%5C%24(%24MyOrganization.OrganizationName)-SecurityGroups-List.json%22%0A%20echo%20%24(%24MyOrganization.OrganizationPAT)%20%7C%20az%20devops%20login%20--org%20%24Organization%0A%20az%20devops%20configure%20--defaults%20organization%3D%24Organization%0A%20%23%24allProjects%20%3D%20az%20devops%20project%20list%20--org%20%24Organization%20--top%201%20%7C%20ConvertFrom-Json%20%7C%20Select-Object%20-ExpandProperty%20value%20%7C%20Sort-Object%20name%20%23Select%201st%20project%20to%20validate%20script%0A%20%23%24allProjects%20%3D%20az%20devops%20project%20list%20--org%20%24Organization%20--top%2010000%20%7C%20ConvertFrom-Json%20%7C%20Select-Object%20-ExpandProperty%20value%20%7C%20Where-Object%20name%20-eq%20%22YourProjectname%22%20%7C%20Sort-Object%20name%20%23Select%20filtered%20project%0A%20%24allProjects%20%3D%20az%20devops%20project%20list%20--org%20%24Organization%20--top%2010000%20%7C%20ConvertFrom-Json%20%7C%20Select-Object%20-ExpandProperty%20value%20%7C%20Sort-Object%20name%20%20%23Select%20all%20projects%0A%20%20%20%20%23%24allProjects%0A%20%23%24allProjects%20%7C%20ConvertTo-Json%20%7C%20Out-File%20-FilePath%20%24GroupsListJSonFilePath%20-Encoding%20UTF8%20%23If%20you%20want%20to%20save%20a%20copy%0A%20%20%20%20%24TotalProjectPerOrganization%20%3D%20%24allProjects.Count%0A%0A%20%20%20%20foreach(%24myProject%20in%20%24allProjects)%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%24ProjectInProgress%20%2B%3D%201%0A%20%20%20%20%20%20%20%20%24ProjectAdministrators%20%3D%20%20%40()%0A%20%20%20%20%20%20%20%20%24EmailPreCoreContentToUse%20%3D%20%22%22%0A%20%20%20%20%20%20%20%20%24EmailCoreTitleToUse%20%3D%20%22%22%0A%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%3D%20%22%22%0A%20%20%20%20%20%20%20%20%24ListToPutInMail%20%3D%20%22%22%0A%20%20%20%20%20%20%20%20%24UserGroupsPerOrganizationAndProject%20%3D%20%40()%0A%20%20%20%20%20%20%20%20write-host%20%22%20----------------------------------------------------------------------------------------------------%20%22%20-ForegroundColor%20Yellow%0A%20%20%20%20%20%20%20%20write-host%20%22%20--%20Project%20Name%3A%22%2C%20%24myProject.Name%2C%20%22%5B%22%2C%20%24ProjectInProgress%2C%20%22%20of%20%22%2C%20%24TotalProjectPerOrganization%2C%20%22%5D%20--%22%20-ForegroundColor%20Yellow%0A%20%20%20%20%20%20%20%20%23write-host%20%22%20%20%20%20%20%26gt%3B%20Project%20Description%3A%22%2C%20%24myProject.Description%20-ForegroundColor%20DarkYellow%0A%20%20%20%20%20%20%20%20write-host%20%22%20----------------------------------------------------------------------------------------------------%20%22%20-ForegroundColor%20Yellow%0A%20%20%20%20%20%20%20%20%24ProjectSecurityGroups%20%3D%20az%20devops%20security%20group%20list%20--org%20%24Organization%20--project%20%24myProject.Name%20%20%7C%20ConvertFrom-Json%0A%20%20%20%20%20%23%24ProjectSecurityGroups%20%7C%20Out-File%20-FilePath%20%24SecurityGroupsJSonFilePath%20%23-Encoding%20UTF8%20%23If%20you%20want%20to%20save%20a%20copy%0A%20%20%20%20%20%20%20%20foreach(%24mySecurityGroup%20in%20%24ProjectSecurityGroups.graphGroups)%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20write-host%20%22%20%20%20%20%20-%20Security%20Group%20Name%3A%22%2C%20%24mySecurityGroup.displayName%2C%20%22-%20Descriptor%3A%22%2C%20%24mySecurityGroup.descriptor%20-ForegroundColor%20Magenta%0A%20%20%20%20%20%20%20%20%20%20%20%20%23write-host%20%22%20%20%20%20%20%20%20%26gt%3B%20Security%20Group%20Description%3A%22%2C%20%24mySecurityGroup.Description%20%20-ForegroundColor%20DarkMagenta%0A%20%20%20%20%20%20%20%20%20%20%20%20%24AllGroupMembers%20%3D%20az%20devops%20security%20group%20membership%20list%20--id%20%24mySecurityGroup.descriptor%20--org%20%24Organization%20--relationship%20members%20%7C%20ConvertFrom-Json%0A%20%20%20%20%20%20%20%20%20%20%20%20%23write-host%20%22%20JSON%3A%22%2C%20%24AllGroupMembers%0A%20%20%20%20%20%20%20%20%20%23%24AllGroupMembers%20%7C%20ConvertTo-Json%20%7C%20Out-File%20-FilePath%20%24UserGroupJSonFilePath%20-Encoding%20UTF8%20%23If%20you%20want%20to%20save%20a%20copy%0A%20%20%20%20%20%20%20%20%20%5Barray%5D%24groupMembers%20%3D%20(%24AllGroupMembers%20%7C%20Get-Member%20-MemberType%20NoteProperty).Name%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20foreach(%24MyUserInGroup%20in%20%24groupMembers)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if(%24AllGroupMembers.%24MyUserInGroup.mailAddress%20-ne%20%24null)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24AccountValidInAzureAD%20%3D%20%22%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20write-host%20%22%20%20%20%20%20%20%20%20%20%20%3D%3D%26gt%3B%20User%20Name%3A%22%2C%20%24AllGroupMembers.%24MyUserInGroup.displayName%2C%20%22-%20Email%3A%22%2C%20%24AllGroupMembers.%24MyUserInGroup.mailAddress%20-ForegroundColor%20Green%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20if(%24AllGroupMembers.%24MyUserInGroup.mailAddress.endswith(%24DomainNameEmail))%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24TempEmailAddress%20%3D%20%24AllGroupMembers.%24MyUserInGroup.mailAddress%20-replace%20%22'%22%2C%20%22''%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24MyAzureADUser%20%3D%20Get-AzureADUser%20-Filter%20%22userPrincipalName%20eq%20'%24(%24TempEmailAddress)'%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if(%24MyAzureADUser)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if(%24MyAzureADUser.AccountEnabled)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24AccountValidInAzureAD%20%3D%20%22ACCOUNT%20VALID%20IN%20AZURE%20ACTIVE%20DIRECTORY%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20else%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24AccountValidInAzureAD%20%3D%20%22ACCOUNT%20DISABLE%20IN%20AZURE%20ACTIVE%20DIRECTORY%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20else%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24AccountValidInAzureAD%20%3D%20%22ACCOUNT%20NOT%20EXIST%20IN%20AZURE%20ACTIVE%20DIRECTORY%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20else%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24AccountValidInAzureAD%20%3D%20%22ACCOUNT%20NOT%20INTERNAL%20EMAIL%20ADDRESS%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24MyUserDetails%20%3D%20az%20devops%20user%20show%20--user%20%24AllGroupMembers.%24MyUserInGroup.mailAddress%20--org%20%24Organization%20%7C%20ConvertFrom-Json%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23write-host%20%22%20JSON%3A%22%2C%20%24MyUserDetails%0A%0A%20%20%20%20%20%20%20%20%24UserGroupsPerOrganizationAndProject%20%2B%3D%20New-Object%20-TypeName%20PSObject%20-Property%20%40%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20UserName%3D%24AllGroupMembers.%24MyUserInGroup.displayName%0A%20%20%20%20%20%20%20%20%20%20%20%20%20mailAddress%3D%24AllGroupMembers.%24MyUserInGroup.mailAddress%0A%20%20%20%20%20%20%20%20%20%20%20%20%20AccountStatusInAzureAD%20%3D%20%24AccountValidInAzureAD%0A%20%20%20%20%20%20%20%20%20%20%20%20%20LicenseType%3D%24MyUserDetails.accessLevel.licenseDisplayName%0A%20%20%20%20%20%20%20%20%20%20%20%20%20dateCreated%3D%24(%24MyUserDetails.dateCreated).Substring(0%2C%2010)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20lastAccessedDate%3D%24(%24MyUserDetails.lastAccessedDate).Substring(0%2C%2010)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20ProjectName%3D%20%24myProject.Name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20GroupName%3D%24mySecurityGroup.displayName%0A%20%20%20%20%20%20%20%20%20%20%20%20%20DataRefreshDate%20%3D%20%24DataRefreshDate%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%24EmailCoreTitleToUse%20%3D%20%24EmailCoreTitle.replace(%22%5B%5Byourproject%5D%5D%22%2C%20%24myProject.Name)%0A%20%20%20%20%20%20%20%20%24ProjectAdministrators%20%3D%20%24UserGroupsPerOrganizationAndProject%20%7C%20where%20%7B%20%24_.GroupName%20-eq%20%22Project%20Administrators%22%20%7D%20%7C%20Select%20mailAddress%0A%20%20%20%20%20%20%20%20if(%24ProjectAdministrators.count%20-gt%200)%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20foreach%20(%24myProjectAdminEMail%20in%20%24ProjectAdministrators)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20(%24myProjectAdminEMail.mailAddress.endswith(%24DomainNameEmail))%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%2B%3D%20%24myProjectAdminEMail.MailAddress%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%2B%3D%20%22%3B%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20if(%24ProjectOwnerEmailAddresses%20-eq%20%22%22)%0A%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%3D%20%24DevOpsAdmins%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%24EmailCoreTitleToUse%20%3D%20%22NO%20INTERNAL%20PROJECT%20ADMIN%20-%20%22%20%2B%20%24EmailCoreTitleToUse%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20else%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%3D%20%24DevOpsAdmins%0A%20%20%20%20%20%20%20%20%20%20%20%20%24EmailCoreTitleToUse%20%3D%20%22NO%20PROJECT%20ADMIN%20-%20%22%20%2B%20%24EmailCoreTitleToUse%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%24UserGroupsPerOrganizationAndProject%20%7C%20ConvertTo-Json%0A%20%20%20%20%20%20%20%20%23write-host%20%22%20JSON%3A%22%2C%20%24UserGroupsPerOrganizationAndProject%0A%20%20%20%20%20%20%20%20%24EmailPreCoreContentToUse%20%3D%20%24EmailPreCoreContent.replace(%22%5B%5Byourproject%5D%5D%22%2C%20%24myProject.Name)%0A%20%20%20%20%20%20%20%20%23%24ListToPutInMail%20%3D%20%24UserGroupsPerOrganizationAndProject%20%7C%20Format-Table%20UserName%2C%20mailAddress%2C%20AccountStatusInAzureAD%2C%20LicenseType%2C%20GroupName%2C%20dateCreated%2C%20lastAccessedDate%20%7C%20Out-String%0A%20%20%20%20%20%20%20%20%24ListToPutInMail%20%3D%20%24UserGroupsPerOrganizationAndProject%20%7C%20ConvertTo-Html%20-Property%20UserName%2C%20mailAddress%2C%20AccountStatusInAzureAD%2C%20LicenseType%2C%20GroupName%2C%20dateCreated%2C%20lastAccessedDate%20-PreContent%20%24EmailPreCoreContentToUse%20-PostContent%20%24EmailPostCoreContent%20-Head%20%24HTMLHeader%20%7C%20Out-String%0A%20%20%20%20%20%20%20%20%23write-host%20%22%20HTML%20Table%3A%22%2C%20%24ListToPutInMail%0A%0A%20%20%20%20%20%20%20%20%23write-host%20%22%20Project%20Admins%3A%22%2C%20%24ProjectAdministrators%0A%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%3D%20%24ProjectOwnerEmailAddresses.Trim()%0A%20%20%20%20%20%20%20%20if(%24ProjectOwnerEmailAddresses.EndsWith(%22%3B%22))%0A%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20write-host%20%22%20%20%20%20Remove%20last%20char%20from%3A%22%2C%20%24ProjectOwnerEmailAddresses%0A%20%20%20%20%20%20%20%20%20%20%20%20%24ProjectOwnerEmailAddresses%20%3D%20%24ProjectOwnerEmailAddresses.Substring(0%2C%20%24ProjectOwnerEmailAddresses.Length-1)%0A%20%20%20%20%20%20%20%20%20%20%20%20write-host%20%22%20%20%20%20Last%20char%20removed%20from%3A%22%2C%20%24ProjectOwnerEmailAddresses%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20write-host%20%22%20Project%20Admin%20Emails%3A%22%2C%20%24ProjectOwnerEmailAddresses%0A%20%20%20%20%20%20%20%20write-host%20%22%20%20%20%20*%26gt%3B%20Email%20Title%3A%22%2C%20%24EmailCoreTitleToUse%0A%20%20%20%20%20%20%20%20Send-MailMessage%20-To%20%24ProjectOwnerEmailAddresses.split('%3B')%20-From%20%24DevOpsAdmins%20-Cc%20%24DevOpsAdmins%20-Subject%20%24EmailCoreTitleToUse%20-Body%20%24ListToPutInMail%20-BodyAsHtml%20-SmtpServer%20%24SMTPServer%0A%20%20%20%20%20%20%20%20write-host%20%22%20----------------------------------------------------------------------------------------------------%20%22%20-ForegroundColor%20Yellow%0A%20%20%20%20%7D%0A%20%20%20%20write-host%20%22%20--------------------------------------------------------------------%22%20-ForegroundColor%20White%20-BackgroundColor%20DarkYellow%0A%7D%3C%2FCODE%3E%3C%2FPRE%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3EYou%20have%20a%20restricted%20list%20of%20settings%20to%20adapt%20and%20run%20the%20script.%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3EIn%20any%20case%2C%20that%20part%20need%20to%20be%20integrated%20into%20your%20FinOps%20process%20to%20optimize%20your%20license%20costs%20as%20you%20really%20need%20and%20use.%26nbsp%3B%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3E%3CSTRONG%3EFabrice%20Romelard%3C%2FSTRONG%3E%3C%2FP%3E%3CP%3E%3CSTRONG%3E%3CEM%3EReferences%20used%20to%20build%20that%20script%3A%3C%2FEM%3E%3C%2FSTRONG%3E%3C%2FP%3E%3CUL%3E%3CLI%3E%3CA%20href%3D%22https%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fcli%2Fazure%2Fext%2Fazure-devops%2Fdevops%2Fproject%3Fview%3Dazure-cli-latest%22%20target%3D%22_blank%22%20rel%3D%22noopener%20noreferrer%22%3E%3CEM%3Ehttps%3A%2F%2Fdocs.microsoft.com%2Fen-us%2Fcli%2Fazure%2Fext%2Fazure-devops%2Fdevops%2Fproject%3Fview%3Dazure-cli-latest%3C%2FEM%3E%3C%2FA%3E%3C%2FLI%3E%3CLI%3E%3CEM%3E%3CA%20href%3D%22https%3A%2F%2Fwww.colinsalmcorner.com%2Faz-devops-like-a-boss%2F%22%20target%3D%22_blank%22%20rel%3D%22noopener%20nofollow%20noreferrer%22%3Ehttps%3A%2F%2Fwww.colinsalmcorner.com%2Faz-devops-like-a-boss%2F%3C%2FA%3E%3C%2FEM%3E%3C%2FLI%3E%3CLI%3E%3CEM%3E%3CA%20href%3D%22https%3A%2F%2Ftenbulls.co.uk%2F2020%2F01%2F06%2Fusing-azure-cli-to-query-azure-devops%2F%22%20target%3D%22_blank%22%20rel%3D%22noopener%20nofollow%20noreferrer%22%3Ehttps%3A%2F%2Ftenbulls.co.uk%2F2020%2F01%2F06%2Fusing-azure-cli-to-query-azure-devops%2F%3C%2FA%3E%3C%2FEM%3E%3C%2FLI%3E%3C%2FUL%3E%3C%2FLINGO-BODY%3E%3CLINGO-LABS%20id%3D%22lingo-labs-2193427%22%20slang%3D%22en-US%22%3E%3CLINGO-LABEL%3EDevOps%3C%2FLINGO-LABEL%3E%3CLINGO-LABEL%3EFinOps%3C%2FLINGO-LABEL%3E%3CLINGO-LABEL%3ELicense%3C%2FLINGO-LABEL%3E%3CLINGO-LABEL%3EPowerShell%3C%2FLINGO-LABEL%3E%3CLINGO-LABEL%3Eproject%20management%3C%2FLINGO-LABEL%3E%3C%2FLINGO-LABS%3E
Regular Contributor

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:

0 Replies