azure-ad-license-status.psm1
Set-StrictMode -Version 3.0 #region: Classes class AdvancedUserResult { [string]$UserPrincipalName [guid]$PreferableSKUID [guid[]]$ReplaceableSKUIDs } class BasicUserResult { [string]$UserPrincipalName [guid[]]$InterchangeableSKUIDs [guid[]]$OptimizableSKUIDs [guid[]]$RemovableSKUIDs } class SKUPrice { [guid]$SKUID [decimal]$Price } class SKURule { [guid]$SKUID [ValidateSet('True', 'False', 'Skip')] [string]$AccountEnabled = [SKURule]::AccountEnabledDefault() [ValidateSet('True', 'False', 'Skip')] [string]$AccountGuest = [SKURule]::AccountGuestDefault() [datetime]$CreatedEarlierThan = [SKURule]::CreatedEarlierThanDefault() [datetime]$LastActiveEarlierThan = [SKURule]::LastActiveEarlierThanDefault() [decimal]$OneDriveGBUsedLessThan = [SKURule]::OneDriveGBUsedLessThanDefault() [decimal]$MailboxGBUsedLessThan = [SKURule]::MailboxGBUsedLessThanDefault() [ValidateSet('True', 'False', 'Skip')] [string]$MailboxHasArchive = [SKURule]::MailboxHasArchiveDefault() [ValidateSet('True', 'False', 'Skip')] [string]$WindowsAppUsed = [SKURule]::WindowsAppUsedDefault() [ValidateSet('True', 'False', 'Skip')] [string]$MacAppUsed = [SKURule]::MacAppUsedDefault() [ValidateSet('True', 'False', 'Skip')] [string]$MobileAppUsed = [SKURule]::MobileAppUsedDefault() [ValidateSet('True', 'False', 'Skip')] [string]$WebAppUsed = [SKURule]::WebAppUsedDefault() static [string]AccountEnabledDefault() { return 'Skip' } static [string]AccountGuestDefault() { return 'Skip' } static [datetime]CreatedEarlierThanDefault() { return [datetime]::MaxValue } static [datetime]LastActiveEarlierThanDefault() { return [datetime]::MaxValue } static [UInt16]OneDriveGBUsedLessThanDefault() { return [UInt16]::MaxValue } static [UInt16]MailboxGBUsedLessThanDefault() { return [UInt16]::MaxValue } static [string]MailboxHasArchiveDefault() { return 'Skip' } static [string]WindowsAppUsedDefault() { return 'Skip' } static [string]MacAppUsedDefault() { return 'Skip' } static [string]MobileAppUsedDefault() { return 'Skip' } static [string]WebAppUsedDefault() { return 'Skip' } } #endregion #region: Functions function Initialize-Module { [OutputType([void])] $script:nestingLevel = 0 $script:outputs = [System.Text.StringBuilder]::new() $script:results = @{} $script:skuTranslate = [System.Text.Encoding]::UTF8.GetString((Invoke-WebRequest -Uri 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv' -UseBasicParsing).Content) | ConvertFrom-Csv $script:appUsage = @{} $script:mailboxUsage = @{} $script:driveUsage = @{} } function Write-Message { [OutputType([void])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Message, [ValidateSet('Error', 'Verbose')] [string]$Type ) $formattedMessage = '[{0:yyyy-MM-dd HH:mm:ss}] {1}{2}' -f [datetime]::Now, [string]::new('-', $nestingLevel), $Message if ($Type -eq 'Error') { Write-Error -Message $formattedMessage -Category AuthenticationError } elseif ($Type -eq 'Verbose') { Write-Verbose -Message $formattedMessage } else { $formattedMessage | Write-Output } } function Add-Output { [OutputType([void])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Output ) # Logging $nestingLevel++ Write-Message 'Add-Output' -Type Verbose # Processing $outputs.AppendLine($Output) | Out-Null $nestingLevel-- } function Add-Result { [OutputType([void])] param ( [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Basic')] [ValidateNotNullOrEmpty()] [guid]$SKUID, [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Basic')] [ValidateNotNullOrEmpty()] [Int32]$AvailableCount, [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Basic')] [ValidateNotNullOrEmpty()] [UInt32]$MinimumCount, [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Advanced')] [ValidateNotNullOrEmpty()] [string]$PlanName, [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Advanced')] [ValidateNotNullOrEmpty()] [UInt32]$EnabledCount, [Parameter(Mandatory = $true, ParameterSetName = 'SKU_Advanced')] [ValidateNotNullOrEmpty()] [UInt32]$NeededCount, [Parameter(Mandatory = $true, ParameterSetName = 'User_Basic')] [BasicUserResult]$BasicUserResult, [Parameter(Mandatory = $true, ParameterSetName = 'User_Advanced')] [AdvancedUserResult]$AdvancedUserResult ) # Logging $nestingLevel++ Write-Message 'Add-Result' -Type Verbose # Processing switch ($PSCmdlet.ParameterSetName) { 'SKU_Basic' { if (-not $results.ContainsKey($PSCmdlet.ParameterSetName)) { $results.Add($PSCmdlet.ParameterSetName, @{}) } if (-not $results[$PSCmdlet.ParameterSetName].ContainsKey($SKUID)) { $results[$PSCmdlet.ParameterSetName].Add($SKUID, @{ 'availableCount' = $AvailableCount 'minimumCount' = $MinimumCount }) } } 'SKU_Advanced' { if (-not $results.ContainsKey($PSCmdlet.ParameterSetName)) { $results.Add($PSCmdlet.ParameterSetName, @{}) } if (-not $results[$PSCmdlet.ParameterSetName].ContainsKey($PlanName)) { $results[$PSCmdlet.ParameterSetName].Add($PlanName, @{ 'enabledCount' = $EnabledCount 'neededCount' = $NeededCount }) } } 'User_Basic' { if (-not $results.ContainsKey($PSCmdlet.ParameterSetName)) { $results.Add($PSCmdlet.ParameterSetName, [System.Collections.Generic.List[BasicUserResult]]::new()) } $results[$PSCmdlet.ParameterSetName].Add($BasicUserResult) } 'User_Advanced' { if (-not $results.ContainsKey($PSCmdlet.ParameterSetName)) { $results.Add($PSCmdlet.ParameterSetName, [System.Collections.Generic.List[AdvancedUserResult]]::new()) } $results[$PSCmdlet.ParameterSetName].Add($AdvancedUserResult) } } $nestingLevel-- } function Get-AADGroupMember { [OutputType([guid[]])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [guid[]]$GroupIDs ) # Logging $nestingLevel++ Write-Message 'Get-AADGroupMember' -Type Verbose # Processing $groupMembers = [System.Collections.Generic.List[hashtable]]::new() foreach ($groupID in $GroupIDs) { $URI = 'https://graph.microsoft.com/v1.0/groups/{0}/transitiveMembers?$select=id&$top={1}' -f $groupID, $pageSize while ($null -ne $URI) { $data = Invoke-MgGraphRequest -Method GET -Uri $URI $groupMembers.AddRange([hashtable[]]($data.value)) $URI = $data['@odata.nextLink'] } } if ($groupMembers.Count -gt 0) { $groupMembers_unique = @($groupMembers.id | Sort-Object -Unique) } else { $groupMembers_unique = @() } Write-Message "Found $($groupMembers_unique.Count) members" -Type Verbose $nestingLevel-- Write-Output ([guid[]]$groupMembers_unique) -NoEnumerate } function Get-EXOGroupMember { [OutputType([psobject[]])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [guid[]]$GroupIDs ) # Logging $nestingLevel++ Write-Message 'Get-EXOGroupMember' -Type Verbose # Processing $groupMembers = [System.Collections.Generic.List[psobject]]::new() foreach ($groupID in $GroupIDs) { if ($null -ne ($group = [pscustomobject](Get-EXORecipient $groupID.Guid -RecipientTypeDetails $EXOTypes_group -Properties $EXOProperties) | Select-Object -Property $EXOProperties)) { switch ($group.RecipientTypeDetails) { 'GroupMailbox' { $members = @(Get-UnifiedGroupLinks $group.ExchangeObjectId.Guid -LinkType Members -ResultSize Unlimited | Select-Object -Property $EXOProperties) } Default { $members = @(Get-DistributionGroupMember $group.ExchangeObjectId.Guid -ResultSize Unlimited | Select-Object -Property $EXOProperties) } } foreach ($member in $members) { switch ($member.RecipientTypeDetails) { {$_ -in $EXOTypes_user} { $groupMembers.Add($member) } {$_ -in $EXOTypes_group} { $groupMembers.AddRange((Get-EXOGroupMember -GroupIDs $member.ExchangeObjectId)) } } } } } $groupMembers_unique = @($groupMembers | Sort-Object -Unique) Write-Message "Found $($groupMembers_unique.Count) members" -Type Verbose $nestingLevel-- Write-Output ([pscustomobject[]]$groupMembers_unique) -NoEnumerate } function Resolve-ATPRecipient { [OutputType([psobject[]])] param ( [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$Users, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$Groups, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$Domains ) # Logging $nestingLevel++ Write-Message 'Resolve-ATPRecipient' -Type Verbose # Processing $categoryCount = 0 $affectedAsUser = [System.Collections.Generic.List[psobject]]::new() $affectedAsGroup = [System.Collections.Generic.List[psobject]]::new() $affectedAsDomain = [System.Collections.Generic.List[psobject]]::new() if ($null -ne $Users) { $categoryCount++ if ($null -ne ($recipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_user -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties | Where-Object{$_.PrimarySmtpAddress -in $Users})) { $affectedAsUser.AddRange([pscustomobject[]]@($recipients)) } } Write-Message "Found $($affectedAsUser.Count) recipients by users" -Type Verbose if ($null -ne $Groups) { $categoryCount++ if ($null -ne ($recipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_group -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties | Where-Object{$_.PrimarySmtpAddress -in $Groups})) { $affectedAsGroup.AddRange((Get-EXOGroupMember -GroupIDs $recipients.ExchangeObjectId)) } } Write-Message "Found $($affectedAsGroup.Count) recipients by groups" -Type Verbose if ($null -ne $Domains) { $categoryCount++ if ($null -ne ($recipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_user -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties | Where-Object{$_.PrimarySmtpAddress.Split('@')[1] -in $Domains})) { $affectedAsDomain.AddRange([pscustomobject[]]@($recipients)) } if ($null -ne ($recipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_group -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties | Where-Object{$_.PrimarySmtpAddress.Split('@')[1] -in $Domains})) { $affectedAsDomain.AddRange((Get-EXOGroupMember -GroupIDs $recipients.ExchangeObjectId)) } } Write-Message "Found $($affectedAsDomain.Count) recipients by domains" -Type Verbose if ($null -ne ($resolvedUsers = @($affectedAsUser | Sort-Object -Unique) + @($affectedAsGroup | Sort-Object -Unique) + @($affectedAsDomain | Sort-Object -Unique) | Group-Object -Property ExchangeObjectId | Where-Object{$_.Count -eq $categoryCount})) { $resolvedUsers_unique = @($resolvedUsers.Group | Sort-Object -Unique) Write-Message "Found $($resolvedUsers_unique.Count) recipients by combination" -Type Verbose $nestingLevel-- Write-Output ([pscustomobject[]]$resolvedUsers_unique) -NoEnumerate } else { Write-Message 'Found 0 recipients by combination' -Type Verbose $nestingLevel-- Write-Output @([psobject[]]::new(0)) -NoEnumerate } } function Get-ATPRecipient { [OutputType([psobject[]])] param ( [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$IncludedUsers, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$IncludedGroups, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$IncludedDomains, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$ExcludedUsers, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$ExcludedGroups, [Parameter(Mandatory = $true)] [AllowNull()] [string[]]$ExcludedDomains ) # Logging $nestingLevel++ Write-Message 'Get-ATPRecipient' -Type Verbose # Processing Write-Message 'Checking included recipients' -Type Verbose if ($null -eq $IncludedUsers -and $null -eq $IncludedGroups -and $null -eq $IncludedDomains) { $userRecipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_user -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties $groupRecipients = Get-EXOGroupMember -GroupIDs ([pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_group -Properties $EXOProperties -ResultSize Unlimited)).ExchangeObjectId $includedRecipients = [pscustomobject[]]@($userRecipients + $groupRecipients | Sort-Object -Unique) } else { $includedRecipients = Resolve-ATPRecipient -Users $IncludedUsers -Groups $IncludedGroups -Domains $IncludedDomains } Write-Message "Found $($includedRecipients.Count) included recipients" -Type Verbose Write-Message 'Checking excluded recipients' -Type Verbose if ($null -eq $ExcludedUsers -and $null -eq $ExcludedGroups -and $null -eq $ExcludedDomains) { $excludedRecipients = @() } else { $excludedRecipients = Resolve-ATPRecipient -Users $ExcludedUsers -Groups $ExcludedGroups -Domains $ExcludedDomains } Write-Message "Found $($excludedRecipients.Count) excluded recipients" -Type Verbose Write-Message 'Checking affected recipients' -Type Verbose $affectedRecipients = [System.Collections.Generic.List[psobject]]::new() if ($null -ne ($affectedRecipientComparison = Compare-Object -ReferenceObject $includedRecipients -DifferenceObject $excludedRecipients)) { if ($null -ne ($affectedRecipientResults = $affectedRecipientComparison | Where-Object{$_.SideIndicator -eq '<='})) { $affectedRecipients.AddRange([pscustomobject[]]@($affectedRecipientResults.InputObject)) } } $affectedRecipients_unique = @($affectedRecipients | Sort-Object -Unique) Write-Message "Found $($affectedRecipients_unique.Count) affected recipients" -Type Verbose $nestingLevel-- Write-Output ([pscustomobject[]]$affectedRecipients_unique) -NoEnumerate } function Get-SKUName { [OutputType([string])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [guid]$SKUID ) # Logging $nestingLevel++ Write-Message 'Get-SKUName' -Type Verbose # Processing if ($SKUID -eq [guid]::Empty) { $skuName = 'N/A' } elseif ($null -ne ($skuName = ($skuTranslate | Where-Object{$_.GUID -eq $SKUID}).Product_Display_Name | Sort-Object -Unique)) { $skuName = [cultureinfo]::new('en-US').TextInfo.ToTitleCase($skuName.ToLower()) } else { $skuName = $SKUID } $nestingLevel-- Write-Output $skuName } #endregion #region: Variables # Exchange Online $EXOCmdlets = @( 'Get-Recipient', 'Get-DistributionGroupMember', 'Get-UnifiedGroupLinks', 'Get-ATPBuiltInProtectionRule', 'Get-ATPProtectionPolicyRule', 'Get-AntiPhishRule', 'Get-AntiPhishPolicy', 'Get-SafeAttachmentRule', 'Get-SafeAttachmentPolicy', 'Get-SafeLinksRule', 'Get-SafeLinksPolicy') $EXOProperties = @( 'ExchangeObjectId', 'ExternalDirectoryObjectId', 'PrimarySmtpAddress', 'RecipientTypeDetails') $EXOTypes_group = @( 'GroupMailbox', 'MailUniversalDistributionGroup', 'MailUniversalSecurityGroup') $EXOTypes_user = @( 'SharedMailbox', 'UserMailbox') # Graph $pageSize = 500 $reportDays = 180 #endregion function Get-AzureADLicenseStatus { # .EXTERNALHELP azure-ad-license-status.psm1-help.xml [CmdletBinding(PositionalBinding = $false)] [OutputType([void])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [guid]$DirectoryID, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [guid]$ApplicationID, [Parameter(Mandatory = $true, ParameterSetName = 'AzureCertificate')] [ValidateNotNullOrEmpty()] [guid]$SubscriptionID, [Parameter(Mandatory = $true, ParameterSetName = 'AzureCertificate')] [ValidateNotNullOrEmpty()] [string]$KeyVaultName, [Parameter(Mandatory = $true, ParameterSetName = 'AzureCertificate')] [ValidateNotNullOrEmpty()] [string]$CertificateName, [Parameter(Mandatory = $true, ParameterSetName = 'LocalCertificate')] [ValidateNotNullOrEmpty()] [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter(Mandatory = $true, ParameterSetName = 'LocalCertificateThumbprint')] [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$SenderAddress, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string[]]$RecipientAddresses_normal, [ValidateNotNullOrEmpty()] [string[]]$RecipientAddresses_critical, [ValidateNotNullOrEmpty()] [UInt32]$SKUIgnoreThreshold = 10, [ValidateRange(0, 100)] [UInt16]$SKUPercentageThreshold_normal = 5, [ValidateNotNullOrEmpty()] [UInt32]$SKUTotalThreshold_normal = 10, [ValidateRange(0, 100)] [UInt16]$SKUPercentageThreshold_important = 5, [ValidateNotNullOrEmpty()] [UInt32]$SKUTotalThreshold_important = 50, [ValidateRange(1, 99)] [UInt16]$SKUWarningThreshold_basic = 80, [ValidateRange(1, 99)] [UInt16]$SKUCriticalThreshold_basic = 20, [ValidateRange(1, 99)] [UInt16]$SKUWarningThreshold_advanced = 99, [ValidateRange(1, 99)] [UInt16]$SKUCriticalThreshold_advanced = 95, [ValidateNotNullOrEmpty()] [guid[]]$ImportantSKUs, [ValidateNotNullOrEmpty()] [guid[]]$InterchangeableSKUs, [ValidateNotNullOrEmpty()] [SKURule[]]$PreferableSKUs, [ValidateNotNullOrEmpty()] [SKUPrice[]]$SKUPrices, [ValidateSet('CSV', 'TranslatedCSV', 'JSON')] [string]$AttachmentFormat, [ValidateNotNullOrEmpty()] [string]$LicensingURL = 'https://www.microsoft.com/licensing/servicecenter', [switch]$AdvancedCheckups ) Initialize-Module try { switch ($PSCmdlet.ParameterSetName) { 'AzureCertificate' { Connect-AzAccount -Identity -Subscription $SubscriptionID $azureCertificateSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $CertificateName -AsPlainText $azureCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($azureCertificateSecret)) Disconnect-AzAccount Connect-MgGraph -Certificate $azureCertificate -TenantId $DirectoryID -ClientId $ApplicationID -ErrorAction Stop | Out-Null } 'LocalCertificate' { Connect-MgGraph -Certificate $Certificate -TenantId $DirectoryID -ClientId $ApplicationID -ErrorAction Stop | Out-Null } 'LocalCertificateThumbprint' { Connect-MgGraph -CertificateThumbprint $CertificateThumbprint -TenantId $DirectoryID -ClientId $ApplicationID -ErrorAction Stop | Out-Null } } $graphAuthentication = $true Write-Message 'Succeeded to authenticate with Graph' -Type Verbose } catch { $graphAuthentication = $false Write-Message -Message 'Failed to authenticate with Graph' -Type Error } if ($graphAuthentication) { #region: SKUs # Get SKUs $organizationSKUs = [System.Collections.Generic.List[hashtable]]::new() $URI = 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,prepaidUnits,consumedUnits,servicePlans' while ($null -ne $URI) { $data = Invoke-MgGraphRequest -Method GET -Uri $URI $organizationSKUs.AddRange([hashtable[]]($data.value)) $URI = $data['@odata.nextLink'] } # Analyze SKUs foreach ($SKU in $organizationSKUs | Where-Object{$_.prepaidUnits.enabled -gt $SKUIgnoreThreshold}) { $totalCount = $SKU.prepaidUnits.enabled $availableCount = $SKU.prepaidUnits.enabled - $SKU.consumedUnits if ($SKU.skuId -in $ImportantSKUs) { $percentageThreshold = $SKUPercentageThreshold_important $totalThreshold = $SKUTotalThreshold_important } else { $percentageThreshold = $SKUPercentageThreshold_normal $totalThreshold = $SKUTotalThreshold_normal } $minimumCount = (@([System.Math]::Ceiling($totalCount * $percentageThreshold / 100), $totalThreshold) | Measure-Object -Minimum).Minimum if ($availableCount -lt $minimumCount) { Add-Result -SKUID $SKU.skuId -AvailableCount $availableCount -MinimumCount $minimumCount } } $superiorSKUs_organization = @{} foreach ($referenceSKU in $organizationSKUs) { foreach ($differenceSKU in $organizationSKUs | Where-Object{$_.skuId -ne $referenceSKU.skuId}) { if ($null -ne ($referenceServicePlans = $referenceSKU.servicePlans | Where-Object{$_.appliesTo -eq 'User'}) -and $null -ne ($differenceServicePlans = $differenceSKU.servicePlans | Where-Object{$_.appliesTo -eq 'User'})) { if ($null -ne ($comparisonSKU = Compare-Object -ReferenceObject $referenceServicePlans.servicePlanId -DifferenceObject $differenceServicePlans.servicePlanId -IncludeEqual) -and $comparisonSKU.SideIndicator -contains '==' -and $comparisonSKU.SideIndicator -notcontains '=>') { if (-not $superiorSKUs_organization.ContainsKey($differenceSKU.skuId)) { $superiorSKUs_organization.Add($differenceSKU.skuId, [System.Collections.Generic.List[guid]]::new()) } $superiorSKUs_organization[$differenceSKU.skuId].Add($referenceSKU.skuId) } } } } Write-Message "Found $($superiorSKUs_organization.Count) SKU matches for organization, out ouf $($organizationSKUs.Count) SKUs" #endregion #region: Reports if ($AdvancedCheckups) { Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/reports/getM365AppUserDetail(period=''D{0}'')?$format=text/csv' -f $reportDays) -OutputFilePath "$env:TEMP\appUsage.csv" foreach ($entry in Import-Csv "$env:TEMP\appUsage.csv" | Select-Object -Property 'User Principal Name', 'Last Activity Date', 'Windows', 'Mac', 'Mobile', 'Web') { if (-not $appUsage.ContainsKey($entry.'User Principal Name')) { $lastActivityDate = [datetime]::MinValue [datetime]::TryParse($entry.'Last Activity Date', [ref]$lastActivityDate) | Out-Null if ($entry.'Windows' -eq 'Yes') { $windowsApp = $true } else { $windowsApp = $false } if ($entry.'Mac' -eq 'Yes') { $macApp = $true } else { $macApp = $false } if ($entry.'Mobile' -eq 'Yes') { $mobileApp = $true } else { $mobileApp = $false } if ($entry.'Web' -eq 'Yes') { $webApp = $true } else { $webApp = $false } $appUsage.Add($entry.'User Principal Name', @{ 'LastActivityDate' = $lastActivityDate 'WindowsApp' = $windowsApp 'MacApp' = $macApp 'MobileApp' = $mobileApp 'WebApp' = $webApp }) } else { Write-Message -Message "Found duplicate user name $($entry.'User Principal Name') in app usage reports" -Type Error } } Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period=''D{0}'')' -f $reportDays) -OutputFilePath "$env:TEMP\mailboxUsage.csv" foreach ($entry in Import-Csv "$env:TEMP\mailboxUsage.csv" | Select-Object -Property 'User Principal Name', 'Is Deleted', 'Last Activity Date', 'Storage Used (Byte)', 'Has Archive' | Where-Object{$_.'Is Deleted' -eq 'False'}) { if (-not $mailboxUsage.ContainsKey($entry.'User Principal Name')) { $lastActivityDate = [datetime]::MinValue [datetime]::TryParse($entry.'Last Activity Date', [ref]$lastActivityDate) | Out-Null $storageUsed = [decimal]($entry.'Storage Used (Byte)' / [System.Math]::Pow(1000, 3)) $hasArchive = [bool]::Parse($entry.'Has Archive') $mailboxUsage.Add($entry.'User Principal Name', @{ 'LastActivityDate' = $lastActivityDate 'StorageUsed' = $storageUsed 'HasArchive' = $hasArchive }) } else { Write-Message -Message "Found duplicate user name $($entry.'User Principal Name') in mailbox usage reports" -Type Error } } Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period=''D{0}'')' -f $reportDays) -OutputFilePath "$env:TEMP\driveUsage.csv" foreach ($entry in Import-Csv "$env:TEMP\driveUsage.csv" | Select-Object -Property 'Owner Principal Name', 'Is Deleted', 'Last Activity Date', 'Storage Used (Byte)' | Where-Object{$_.'Is Deleted' -eq 'False'}) { if (-not $driveUsage.ContainsKey($entry.'Owner Principal Name')) { $lastActivityDate = [datetime]::MinValue [datetime]::TryParse($entry.'Last Activity Date', [ref]$lastActivityDate) | Out-Null $storageUsed = [decimal]($entry.'Storage Used (Byte)' / [System.Math]::Pow(1000, 3)) $driveUsage.Add($entry.'Owner Principal Name', @{ 'LastActivityDate' = $lastActivityDate 'StorageUsed' = $storageUsed }) } else { Write-Message -Message "Found duplicate user name $($entry.'Owner Principal Name') in OneDrive usage reports" -Type Error } } if ($appUsage.Keys.Count -gt 0 -and $mailboxUsage.Keys.Count -gt 0 -and $driveUsage.Keys.Count -gt 0) { if ($appUsage.Keys -like '*@*' -or $mailboxUsage.Keys -like '*@*' -or $driveUsage.Keys -like '*@*') { $hashedReports = $false } else { $hashedReports = $true } $reportsRetrieved = $true Write-Message -Message 'Succeeded to retrieve usage reports' -Type Verbose } else { $reportsRetrieved = $false Write-Message -Message 'Failed to retrieve usage reports' -Type Error } } #endregion #region: Users # Retrieve users $users = [System.Collections.Generic.List[hashtable]]::new() $URI = 'https://graph.microsoft.com/v1.0/users?$select=id,accountEnabled,createdDateTime,licenseAssignmentStates,userPrincipalName,userType&$top={0}' -f $pageSize while ($null -ne $URI) { $data = Invoke-MgGraphRequest -Method GET -Uri $URI -Headers @{'ConsistencyLevel' = 'eventual'} $users.AddRange([hashtable[]]($data.value)) $URI = $data['@odata.nextLink'] } # Analyze users foreach ($user in $users) { if ($user.licenseAssignmentStates.Count -gt 0) { if ($null -ne ($userSKUAssignments = $user.licenseAssignmentStates | Where-Object{$_.state -eq 'Active' -or $_.error -in @('CountViolation', 'MutuallyExclusiveViolation')})) { $userSKUs = $userSKUAssignments.skuId } else { $userSKUs = @() } if ($null -ne ($countViolations = $user.licenseAssignmentStates | Where-Object{$_.error -eq 'CountViolation'})) { foreach ($countViolation in $countViolations.skuId | Sort-Object -Unique) { $results['SKU_Basic'][[guid]$countViolation]['availableCount'] -= 1 } } # Identify interchangeable SKUs, based on specifications $userSKUs_interchangeable = @() if ($null -ne $userSKUs -and $null -ne $InterchangeableSKUs) { if ($null -ne ($comparison_interchangeable = Compare-Object -ReferenceObject $userSKUs -DifferenceObject $InterchangeableSKUs -ExcludeDifferent -IncludeEqual)) { $userSKUs_interchangeable = @($comparison_interchangeable.InputObject) } } # Identify optimizable SKUs, based on organization-level calculations if ($null -ne ($comparison_replaceableOrganization = $userSKUs | Where-Object{$_ -in $superiorSKUs_organization.Keys} | ForEach-Object{$superiorSKUs_organization[$_]})) { $userSKUs_optimizable = Compare-Object -ReferenceObject $userSKUs -DifferenceObject $comparison_replaceableOrganization -ExcludeDifferent -IncludeEqual | ForEach-Object{$superiorSKU = $_.InputObject; $superiorSKUs_organization.Keys | Where-Object{$superiorSKUs_organization[$_] -contains $superiorSKU}} | Where-Object{$_ -in $userSKUs} | Sort-Object -Unique } else { $userSKUs_optimizable = $null } # Identify removable SKUs, based on user-level calculations $skuid_enabledPlans = @{} foreach ($skuid in $user.licenseAssignmentStates.skuid | Where-Object{$organizationSKUs.skuId -contains $_} | Sort-Object -Unique) { if (-not $skuid_enabledPlans.ContainsKey($skuid)) { $skuid_enabledPlans.Add($skuid, [System.Collections.Generic.List[guid]]::new()) } foreach ($assignment in $user.licenseAssignmentStates | Where-Object{$_.skuid -eq $skuid}) { $skuid_enabledPlans[$skuid].AddRange([guid[]]@((($organizationSKUs | Where-Object{$_.skuid -eq $skuid}).servicePlans | Where-Object{$_.servicePlanId -notin $assignment.disabledPlans -and $_.appliesTo -eq 'User'}).servicePlanId)) } } $superiorSKUs_user = @{} foreach ($referenceSKU in $skuid_enabledPlans.Keys) { foreach ($differenceSKU in $skuid_enabledPlans.Keys | Where-Object{$_ -ne $referenceSKU}) { if ($null -ne ($referenceServicePlans = $skuid_enabledPlans[$referenceSKU]) -and $null -ne ($differenceServicePlans = $skuid_enabledPlans[$differenceSKU])) { if ($null -ne ($comparisonSKU = Compare-Object -ReferenceObject $referenceServicePlans -DifferenceObject $differenceServicePlans -IncludeEqual) -and $comparisonSKU.SideIndicator -contains '==' -and $comparisonSKU.SideIndicator -notcontains '=>') { if (-not $superiorSKUs_user.ContainsKey($differenceSKU)) { $superiorSKUs_user.Add($differenceSKU, [System.Collections.Generic.List[guid]]::new()) } $superiorSKUs_user[$differenceSKU].Add($referenceSKU) } } } } if ($null -ne ($comparison_replaceableUser = $userSKUs | Where-Object{$_ -in $superiorSKUs_user.Keys} | ForEach-Object{$superiorSKUs_user[$_]})) { $userSKUs_removable = Compare-Object -ReferenceObject $userSKUs -DifferenceObject $comparison_replaceableUser -ExcludeDifferent -IncludeEqual | ForEach-Object{$superiorSKU = $_.InputObject; $superiorSKUs_user.Keys | Where-Object{$superiorSKUs_user[$_] -contains $superiorSKU}} | Where-Object{$_ -in $userSKUs} | Sort-Object -Unique } else { $userSKUs_removable = $null } # Identify preferable SKUs, based on user-level calculations $userSKUs_preferable = $null if ($AdvancedCheckups -and $reportsRetrieved) { $hashCalculator = [System.Security.Cryptography.MD5]::Create() if ($hashedReports) { $userName = ($hashCalculator.ComputeHash([Text.Encoding]::ASCII.GetBytes($user.userPrincipalName)) | ForEach-Object{'{0:X2}' -f $_}) -join '' } else { $userName = $user.userPrincipalName } if ($appUsage.ContainsKey($userName) -or $mailboxUsage.ContainsKey($userName) -or $driveUsage.ContainsKey($userName)) { if ($appUsage.ContainsKey($userName)) { $userAppsUsedLastActivityDate = $appUsage[$userName]['LastActivityDate'] $userWindowsAppUsed = $appUsage[$userName]['WindowsApp'] $userMacAppUsed = $appUsage[$userName]['MacApp'] $userMobileAppUsed = $appUsage[$userName]['MobileApp'] $userWebAppUsed = $appUsage[$userName]['WebApp'] } else { $userAppsUsedLastActivityDate = [datetime]::MinValue $userWindowsAppUsed = $false $userMacAppUsed = $false $userMobileAppUsed = $false $userWebAppUsed = $false } if ($mailboxUsage.ContainsKey($userName)) { $userMailboxLastActivityDate = $mailboxUsage[$userName]['LastActivityDate'] $userMailboxStorageUsedGB = $mailboxUsage[$userName]['StorageUsed'] $userMailboxHasArchive = $mailboxUsage[$userName]['HasArchive'] } else { $userMailboxLastActivityDate = [datetime]::MinValue $userMailboxStorageUsedGB = 0 $userMailboxHasArchive = $false } if ($driveUsage.ContainsKey($userName)) { $userOneDriveLastActivityDate = $driveUsage[$userName]['LastActivityDate'] $userOneDriveStorageUsedGB = $driveUsage[$userName]['StorageUsed'] } else { $userOneDriveLastActivityDate = [datetime]::MinValue $userOneDriveStorageUsedGB = 0 } foreach ($preferableSKU in $PreferableSKUs) { if (($user.accountEnabled.ToString() -eq $preferableSKU.AccountEnabled -or $preferableSKU.AccountEnabled -eq 'Skip') -and (($user.userType -eq 'Guest').ToString() -eq $preferableSKU.AccountGuest -or $preferableSKU.AccountGuest -eq 'Skip') -and $user.createdDateTime -lt $preferableSKU.CreatedEarlierThan -and $userAppsUsedLastActivityDate -lt $preferableSKU.LastActiveEarlierThan.Date -and ($userWindowsAppUsed.ToString() -eq $preferableSKU.WindowsAppUsed -or $preferableSKU.WindowsAppUsed -eq 'Skip') -and ($userMacAppUsed.ToString() -eq $preferableSKU.MacAppUsed -or $preferableSKU.MacAppUsed -eq 'Skip') -and ($userMobileAppUsed.ToString() -eq $preferableSKU.MobileAppUsed -or $preferableSKU.MobileAppUsed -eq 'Skip') -and ($userWebAppUsed.ToString() -eq $preferableSKU.WebAppUsed -or $preferableSKU.WebAppUsed -eq 'Skip') -and $userMailboxLastActivityDate -lt $preferableSKU.LastActiveEarlierThan.Date -and $userMailboxStorageUsedGB -lt $preferableSKU.MailboxGBUsedLessThan -and ($userMailboxHasArchive.ToString() -eq $preferableSKU.MailboxHasArchive -or $preferableSKU.MailboxHasArchive -eq 'Skip') -and $userOneDriveLastActivityDate -lt $preferableSKU.LastActiveEarlierThan.Date -and $userOneDriveStorageUsedGB -lt $preferableSKU.OneDriveGBUsedLessThan) { if ((($InterchangeableSKUs -contains $preferableSKU.SKUID -and $userSKUs -notcontains $preferableSKU.SKUID) -or [guid]::Empty -eq $preferableSKU.SKUID) -and $userSKUs_interchangeable.Count -gt 0) { $userSKUs_preferable = $preferableSKU.SKUID } break } } } } # Add results if ($userSKUs_interchangeable.Count -gt 1) { Write-Message "Found $($userSKUs_interchangeable.Count) interchangeable SKUs for user $($user.userPrincipalName)" $basicResults_interchangeable = $userSKUs_interchangeable } else { $basicResults_interchangeable = [guid[]]::new(0) } if ($null -ne $userSKUs_optimizable) { Write-Message "Found $(@($userSKUs_optimizable).Count) optimizable SKUs for user $($user.userPrincipalName)" $basicResults_optimizable = $userSKUs_optimizable } else { $basicResults_optimizable = [guid[]]::new(0) } if ($null -ne $userSKUs_removable) { Write-Message "Found $(@($userSKUs_removable).Count) removable SKUs for user $($user.userPrincipalName)" $basicResults_removable = $userSKUs_removable } else { $basicResults_removable = [guid[]]::new(0) } if ($basicResults_interchangeable.Count -gt 0 -or $basicResults_optimizable.Count -gt 0 -or $basicResults_removable.Count -gt 0) { Add-Result -BasicUserResult ([BasicUserResult]@{ 'UserPrincipalName' = $user.userPrincipalName 'InterchangeableSKUIDs' = $basicResults_interchangeable 'OptimizableSKUIDs' = $basicResults_optimizable 'RemovableSKUIDs' = $basicResults_removable }) } if ($null -ne $userSKUs_preferable) { Write-Message "Found preferable SKU for user $($user.userPrincipalName)" Add-Result -AdvancedUserResult ([AdvancedUserResult]@{ 'UserPrincipalName' = $user.userPrincipalName 'PreferableSKUID' = $userSKUs_preferable 'ReplaceableSKUIDs' = $userSKUs_interchangeable }) } } } Write-Message "Analyzed $($users.Count) users" #endregion #region: Advanced if ($AdvancedCheckups.IsPresent) { $AADP1Users = [System.Collections.Generic.List[guid]]::new() $AADP2Users = [System.Collections.Generic.List[guid]]::new() $ATPUsers = [System.Collections.Generic.List[guid]]::new() $IntuneDevices = [System.Collections.Generic.List[guid]]::new() # Azure AD P1 based on groups using dynamic user membership $dynamicGroupCount = 0 $URI = 'https://graph.microsoft.com/v1.0/groups?$filter=groupTypes/any(x:x eq ''DynamicMembership'')&$select=id,membershipRule&$top={0}' -f $pageSize while ($null -ne $URI) { # Retrieve dynamic groups $data = Invoke-MgGraphRequest -Method GET -Uri $URI $dynamicGroups = [hashtable[]]($data.value) $dynamicGroupCount += $dynamicGroups.Count $URI = $data['@odata.nextLink'] # Analyze dynamic groups if ($null -ne ($dynamicUserGroups = $dynamicGroups | Where-Object{$_.membershipRule -like '*user.*'})) { $AADP1Users.AddRange((Get-AADGroupMember -GroupIDs $dynamicUserGroups.id)) } } Write-Message "Analyzed $dynamicGroupCount dynamic groups" # Azure AD P1 based on applications using group-based assignment $applicationCount = 0 $URI = 'https://graph.microsoft.com/v1.0/servicePrincipals?$filter=accountEnabled eq true and appRoleAssignmentRequired eq true and servicePrincipalType eq ''Application''&$top={0}&$count=true' -f $pageSize while ($null -ne $URI) { # Retrieve applications $data = Invoke-MgGraphRequest -Method GET -Uri $URI -Headers @{'ConsistencyLevel' = 'eventual'} $applications = [hashtable[]]($data.value) $applicationCount += $applications.Count $URI = $data['@odata.nextLink'] # Analyze applications foreach ($application in $applications) { $applicationData = Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/servicePrincipals/{0}?$expand=appRoleAssignedTo&$select=id,appRoleAssignedTo' -f $application.id) if ($null -ne ($applicationGroups = $applicationData.appRoleAssignedTo | Where-Object{$_.principalType -eq 'Group'})) { $AADP1Users.AddRange((Get-AADGroupMember -GroupIDs $applicationGroups.principalId)) } } } Write-Message "Analyzed $applicationCount applications" # Azure AD P1/P2 based on users covered by Conditional Access $conditionalAccessPolicyCount = 0 $URI = 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies?$select=conditions&$filter=state eq ''enabled''' while ($null -ne $URI) { # Retrieve Conditional Access policies $data = Invoke-MgGraphRequest -Method GET -Uri $URI $conditionalAccessPolicies = [hashtable[]]($data.value) $conditionalAccessPolicyCount += $conditionalAccessPolicies.Count $URI = $data['@odata.nextLink'] # Analyze Conditional Access policies foreach ($conditionalAccessPolicy in $conditionalAccessPolicies) { if ($conditionalAccessPolicy.conditions.users.includeUsers -notcontains 'None') { if ($conditionalAccessPolicy.conditions.users.includeUsers -contains 'All') { $includeUsers = $users.id } elseif ($null -eq ($includeUsers = @($conditionalAccessPolicy.conditions.users.includeUsers | Where-Object{$_ -ne 'GuestsOrExternalUsers'}))) { $includeUsers = @() } $excludeUsers = $conditionalAccessPolicy.conditions.users.excludeUsers if ($conditionalAccessPolicy.conditions.users.includeGroups.Count -gt 0) { $includeGroupUsers = Get-AADGroupMember -GroupIDs $conditionalAccessPolicy.conditions.users.includeGroups } else { $includeGroupUsers = @() } if ($conditionalAccessPolicy.conditions.users.excludeGroups.Count -gt 0) { $excludeGroupUsers = Get-AADGroupMember -GroupIDs $conditionalAccessPolicy.conditions.users.excludeGroups } else { $excludeGroupUsers = @() } if ($null -ne ($conditionalAccessUsers = Compare-Object -ReferenceObject ([guid[]]$includeUsers + [guid[]]$includeGroupUsers) -DifferenceObject ([guid[]]$excludeUsers + [guid[]]$excludeGroupUsers) | Where-Object{$_.SideIndicator -eq '<='})) { if ($conditionalAccessPolicy.conditions.userRiskLevels.Count -gt 0 -or $conditionalAccessPolicy.conditions.signInRiskLevels.Count -gt 0) { $AADP2Users.AddRange([guid[]]@($conditionalAccessUsers.InputObject)) } else { $AADP1Users.AddRange([guid[]]@($conditionalAccessUsers.InputObject)) } } } } } Write-Message "Analyzed $conditionalAccessPolicyCount conditional access policies" # Azure AD P2 based on users eligible for Privileged Identity Management $roleAssignmentCount = 0 $URI = 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?$select=principalId,scheduleInfo' while ($null -ne $URI) { # Retrieve role assignments $data = Invoke-MgGraphRequest -Method GET -Uri $URI $roleAssignments = [hashtable[]]($data.value) $roleAssignmentCount += $roleAssignments.Count $URI = $data['@odata.nextLink'] # Analyze role assignments if ($null -ne ($eligibleRoleAssignments = $roleAssignments | Where-Object{$_.scheduleInfo.startDateTime -le [datetime]::Today -and ($_.scheduleInfo.expiration.endDateTime -ge [datetime]::Today -or $_.scheduleInfo.expiration.type -eq 'noExpiration')})) { $AADP2Users.AddRange([guid[]]@($eligibleRoleAssignments.principalId)) } } Write-Message "Analyzed $roleAssignmentCount role assignments" # Defender for Office 365 P1/P2 based on https://learn.microsoft.com/office365/servicedescriptions/office-365-advanced-threat-protection-service-description#licensing-terms $orgDomain = (Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization?$select=verifiedDomains').value.verifiedDomains | Where-Object{$_.isInitial -eq $true} try { switch ($PSCmdlet.ParameterSetName) { 'AzureCertificate' { Connect-ExchangeOnline -AppId $ApplicationID -Certificate $azureCertificate -Organization $orgDomain.name -CommandName $EXOCmdlets -ShowBanner:$false -ErrorAction Stop } 'LocalCertificate' { Connect-ExchangeOnline -AppId $ApplicationID -Certificate $Certificate -Organization $orgDomain.name -CommandName $EXOCmdlets -ShowBanner:$false -ErrorAction Stop } 'LocalCertificateThumbprint' { Connect-ExchangeOnline -AppId $ApplicationID -CertificateThumbprint $CertificateThumbprint -Organization $orgDomain.name -CommandName $EXOCmdlets -ShowBanner:$false -ErrorAction Stop } } $exchangeAuthentication = $true Write-Message 'Succeeded to authenticate with Exchange Online' -Type Verbose } catch { $exchangeAuthentication = $false Write-Message -Message 'Failed to authenticate with Exchange Online' -Type Error } if ($exchangeAuthentication) { if ($null -ne (Compare-Object -ReferenceObject $organizationSKUs.servicePlans.servicePlanId -DifferenceObject @('f20fedf3-f3c3-43c3-8267-2bfdd51c0939', '8e0c0a52-6a6c-4d40-8370-dd62790dcd70') -ExcludeDifferent -IncludeEqual)) { # Protected mailboxes if ($null -ne ($organizationSKUs | Where-Object{@($_.servicePlans.servicePlanId) -contains '8e0c0a52-6a6c-4d40-8370-dd62790dcd70'})) { $ATPvariant = 'DfOP2' Write-Message 'Identified a Defender for Office P2 tenant' if ($null -ne ($recipients = [pscustomobject[]]@(Get-EXORecipient -RecipientTypeDetails $EXOTypes_user -Properties $EXOProperties -ResultSize Unlimited) | Select-Object -Property $EXOProperties)) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected/protected recipients" } } else { $ATPvariant = 'DfOP1' Write-Message 'Identified a Defender for Office P1 tenant' # Order of precedence according to https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/preset-security-policies?view=o365-worldwide#order-of-precedence-for-preset-security-policies-and-other-policies # Handle strict protection rule if ($null -ne ($strictProtectionRule = Get-ATPProtectionPolicyRule -Identity 'Strict Preset Security Policy' -State Enabled -ErrorAction SilentlyContinue)) { Write-Message 'ATP strict rule' if (($recipients = Get-ATPRecipient -IncludedUsers $strictProtectionRule.SentTo -IncludedGroups $strictProtectionRule.SentToMemberOf -IncludedDomains $strictProtectionRule.RecipientDomainIs -ExcludedUsers $strictProtectionRule.ExceptIfSentTo -ExcludedGroups $strictProtectionRule.ExceptIfSentToMemberOf -ExcludedDomains $strictProtectionRule.ExceptIfRecipientDomainIs).Count -gt 0) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected/protected recipients" } } # Handle standard protection rule if ($null -ne ($standardProtectionRule = Get-ATPProtectionPolicyRule -Identity 'Standard Preset Security Policy' -State Enabled -ErrorAction SilentlyContinue)) { Write-Message 'ATP standard rule' if (($recipients = Get-ATPRecipient -IncludedUsers $standardProtectionRule.SentTo -IncludedGroups $standardProtectionRule.SentToMemberOf -IncludedDomains $standardProtectionRule.RecipientDomainIs -ExcludedUsers $standardProtectionRule.ExceptIfSentTo -ExcludedGroups $standardProtectionRule.ExceptIfSentToMemberOf -ExcludedDomains $standardProtectionRule.ExceptIfRecipientDomainIs).Count -gt 0) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected/protected recipients" } } # Handle custom protection rules foreach ($customAntiPhishPolicy in Get-AntiPhishPolicy | Where-Object{$_.Identity -ne 'Office365 AntiPhish Default' -and $_.RecommendedPolicyType -notin @('Standard', 'Strict')}) { if (($customAntiPhishRule = Get-AntiPhishRule | Where-Object{$_.AntiPhishPolicy -eq $customAntiPhishPolicy.Identity}).State -eq 'Enabled'){ Write-Message "ATP custom anti-phishing policy '$($customAntiPhishPolicy.Name)'" if (($recipients = Get-ATPRecipient -IncludedUsers $customAntiPhishRule.SentTo -IncludedGroups $customAntiPhishRule.SentToMemberOf -IncludedDomains $customAntiPhishRule.RecipientDomainIs -ExcludedUsers $customAntiPhishRule.ExceptIfSentTo -ExcludedGroups $customAntiPhishRule.ExceptIfSentToMemberOf -ExcludedDomains $customAntiPhishRule.ExceptIfRecipientDomainIs).Count -gt 0) { if ($customAntiPhishPolicy.Enabled) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected, $($recipients.Count) protected recipients" } else { Write-Message "Found $($recipients.Count) affected, 0 protected recipients" } } } } foreach ($customSafeAttachmentPolicy in Get-SafeAttachmentPolicy | Where-Object{$_.IsBuiltInProtection -eq $false -and $_.RecommendedPolicyType -notin @('Standard', 'Strict')}) { if (($customSafeAttachmentRule = Get-SafeAttachmentRule | Where-Object{$_.SafeAttachmentPolicy -eq $customSafeAttachmentPolicy.Identity}).State -eq 'Enabled'){ Write-Message "ATP custom Safe Attachments policy '$($customSafeAttachmentPolicy.Name)'" if (($recipients = Get-ATPRecipient -IncludedUsers $customSafeAttachmentRule.SentTo -IncludedGroups $customSafeAttachmentRule.SentToMemberOf -IncludedDomains $customSafeAttachmentRule.RecipientDomainIs -ExcludedUsers $customSafeAttachmentRule.ExceptIfSentTo -ExcludedGroups $customSafeAttachmentRule.ExceptIfSentToMemberOf -ExcludedDomains $customSafeAttachmentRule.ExceptIfRecipientDomainIs).Count -gt 0) { if ($customSafeAttachmentPolicy.Enable) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected, $($recipients.Count) protected recipients" } else { Write-Message "Found $($recipients.Count) affected, 0 protected recipients" } } } } foreach ($customSafeLinksPolicy in Get-SafeLinksPolicy | Where-Object{$_.IsBuiltInProtection -eq $false -and $_.RecommendedPolicyType -notin @('Standard', 'Strict')}) { if (($customSafeLinksRule = Get-SafeLinksRule | Where-Object{$_.SafeLinksPolicy -eq $customSafeLinksPolicy.Identity}).State -eq 'Enabled'){ Write-Message "ATP custom Safe Links policy '$($customSafeLinksPolicy.Name)'" if (($recipients = Get-ATPRecipient -IncludedUsers $customSafeLinksRule.SentTo -IncludedGroups $customSafeLinksRule.SentToMemberOf -IncludedDomains $customSafeLinksRule.RecipientDomainIs -ExcludedUsers $customSafeLinksRule.ExceptIfSentTo -ExcludedGroups $customSafeLinksRule.ExceptIfSentToMemberOf -ExcludedDomains $customSafeLinksRule.ExceptIfRecipientDomainIs).Count -gt 0) { if ($customSafeLinksPolicy.EnableSafeLinksForEmail -or $customSafeLinksPolicy.EnableSafeLinksForOffice -or $customSafeLinksPolicy.EnableSafeLinksForTeams) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected, $($recipients.Count) protected recipients" } else { Write-Message "Found $($recipients.Count) affected, 0 protected recipients" } } } } # Handle built-in protection rule Write-Message 'ATP built-in rule' $builtinProtectionRule = Get-ATPBuiltInProtectionRule if (($recipients = Get-ATPRecipient -IncludedUsers $builtinProtectionRule.SentTo -IncludedGroups $builtinProtectionRule.SentToMemberOf -IncludedDomains $builtinProtectionRule.RecipientDomainIs -ExcludedUsers $builtinProtectionRule.ExceptIfSentTo -ExcludedGroups $builtinProtectionRule.ExceptIfSentToMemberOf -ExcludedDomains $builtinProtectionRule.ExceptIfRecipientDomainIs).Count -gt 0) { $ATPUsers.AddRange([guid[]]@($recipients.ExternalDirectoryObjectId)) Write-Message "Found $($recipients.Count) affected/protected recipients" } } } else { $ATPvariant = 'EOP' Write-Message 'Identified an Exchange Online Protection tenant' } Disconnect-ExchangeOnline -Confirm:$false } # Intune Device based on devices managed by Intune and used by unlicensed users $managedDeviceCount = 0 # Retrieve Intune licensed users $intuneUsers = [System.Collections.Generic.List[hashtable]]::new() $URI = 'https://graph.microsoft.com/v1.0/users?$filter=assignedPlans/any(x:x/servicePlanId eq c1ec4a95-1f05-45b3-a911-aa3fa01094f5 and capabilityStatus eq ''Enabled'') or assignedPlans/any(x:x/servicePlanId eq 3e170737-c728-4eae-bbb9-3f3360f7184c and capabilityStatus eq ''Enabled'')&$select=id&top={0}&$count=true' -f $pageSize while ($null -ne $URI) { $data = Invoke-MgGraphRequest -Method GET -Uri $URI -Headers @{'ConsistencyLevel' = 'eventual'} $intuneUsers.AddRange([hashtable[]]($data.value)) $URI = $data['@odata.nextLink'] } $URI = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=id,userId&$top={0}' -f $pageSize while ($null -ne $URI) { # Retrieve managed devices $data = Invoke-MgGraphRequest -Method GET -Uri $URI $managedDevices = [hashtable[]]($data.value) $managedDeviceCount += $managedDevices.Count $URI = $data['@odata.nextLink'] # Analyze managed devices foreach ($managedDevice in $managedDevices) { if ($managedDevice.userId -notin $intuneUsers.id) { $IntuneDevices.Add($managedDevice.id) } } } Write-Message "Analyzed $managedDeviceCount managed devices" # Add results if ($AADP1Users.Count -gt 0) { if ($null -ne ($AADP1SKUs = @($organizationSKUs | Where-Object{@($_.servicePlans.servicePlanId) -contains '41781fb2-bc02-4b7c-bd55-b576c07bb09d'}))) { $AADP1Licenses = ($AADP1SKUs.prepaidUnits.enabled | Measure-Object -Sum).Sum } else { $AADP1Licenses = 0 } $neededCount = @($AADP1Users | Sort-Object -Unique).Count Write-Message "Found $neededCount needed, $AADP1Licenses enabled AADP1 licenses" if ($AADP1Licenses -lt $neededCount) { Add-Result -PlanName 'Azure Active Directory Premium P1' -EnabledCount $AADP1Licenses -NeededCount $neededCount } } if ($AADP2Users.Count -gt 0) { if ($null -ne ($AADP2SKUs = @($organizationSKUs | Where-Object{@($_.servicePlans.servicePlanId) -contains 'eec0eb4f-6444-4f95-aba0-50c24d67f998'}))) { $AADP2Licenses = ($AADP2SKUs.prepaidUnits.enabled | Measure-Object -Sum).Sum } else { $AADP2Licenses = 0 } $neededCount = @($AADP2Users | Sort-Object -Unique).Count Write-Message "Found $neededCount needed, $AADP2Licenses enabled AADP2 licenses" if ($AADP2Licenses -lt $neededCount) { Add-Result -PlanName 'Azure Active Directory Premium P2' -EnabledCount $AADP2Licenses -NeededCount $neededCount } } if ($ATPUsers.Count -gt 0) { $neededCount = @($ATPUsers | Sort-Object -Unique).Count switch ($ATPvariant) { 'DfOP1' { $ATPSKUs = @($organizationSKUs | Where-Object{@($_.servicePlans.servicePlanId) -contains 'f20fedf3-f3c3-43c3-8267-2bfdd51c0939'}) $ATPLicenses = ($ATPSKUs.prepaidUnits.enabled | Measure-Object -Sum).Sum Write-Message "Found $neededCount needed, $ATPLicenses enabled DfOP2 licenses" if ($ATPLicenses -lt $neededCount) { Add-Result -PlanName 'Microsoft Defender for Office 365 P1' -EnabledCount $ATPLicenses -NeededCount $neededCount } } 'DfOP2' { $ATPSKUs = @($organizationSKUs | Where-Object{@($_.servicePlans.servicePlanId) -contains '8e0c0a52-6a6c-4d40-8370-dd62790dcd70'}) $ATPLicenses = ($ATPSKUs.prepaidUnits.enabled | Measure-Object -Sum).Sum Write-Message "Found $neededCount needed, $ATPLicenses enabled DfOP2 licenses" if ($ATPLicenses -lt $neededCount) { Add-Result -PlanName 'Microsoft Defender for Office 365 P2' -EnabledCount $ATPLicenses -NeededCount $neededCount } } } } if ($IntuneDevices.Count -gt 0) { if ($null -ne ($IntuneDeviceSKUs = @($organizationSKUs | Where-Object{$_.skuId -eq '2b317a4a-77a6-4188-9437-b68a77b4e2c6'}))) { $IntuneDeviceLicenses = ($IntuneDeviceSKUs.prepaidUnits.enabled | Measure-Object -Sum).Sum } else { $IntuneDeviceLicenses = 0 } $neededCount = @($IntuneDevices | Sort-Object -Unique).Count Write-Message "Found $neededCount needed, $IntuneDeviceLicenses enabled Intune Device licenses" if ($IntuneDeviceLicenses -lt $neededCount) { Add-Result -PlanName 'Intune Device' -EnabledCount $IntuneDeviceLicenses -NeededCount $neededCount } } } #endregion #region: Report if ($results.Keys.Count -gt 0) { $critical = $false Add-Output -Output '<style> table, th, td { border: none; border-collapse: collapse; } th, td { padding: 5px; vertical-align: top; } th { text-align: center; } td { text-align: left; } .gray { border-left: 4pt solid darkslategray; padding-left: 4pt; background-color: lightslategray } .green { border-left: 4pt solid darkgreen; padding-left: 4pt; background-color: lightgreen } .yellow { border-left: 4pt solid darkgoldenrod; padding-left: 4pt; background-color: lightgoldenrodyellow } .red { border-left: 4pt solid darkred; padding-left: 4pt; background-color: lightcoral } .rule { border-left-style: solid; } </style>' # Output basic SKU results Add-Output -Output '<p class=gray>Basic checkup - Products</p>' if ($results.ContainsKey('SKU_Basic')) { Add-Output -Output ('<p>Please check license counts for the following product SKUs and <a href="{0}">reserve</a> additional licenses:</p> <p><table> <tr><th>License type</th> <th>Available count</th> <th>Minimum count</th> <th>Difference</th></tr>' -f $LicensingURL) foreach ($SKU in $results['SKU_Basic'].Keys) { $differenceCount = $results['SKU_Basic'][$SKU]['availableCount'] - $results['SKU_Basic'][$SKU]['minimumCount'] Add-Output -Output ('<tr><td>{0}</td><td>{1}</td><td>{2}</td>' -f (Get-SKUName -SKUID $SKU), $results['SKU_Basic'][$SKU]['availableCount'], $results['SKU_Basic'][$SKU]['minimumCount']) if ($results['SKU_Basic'][$SKU]['availableCount'] / $results['SKU_Basic'][$SKU]['minimumCount'] * 100 -ge $SKUWarningThreshold_basic) { Add-Output -Output "<td class=green>$differenceCount</td>" } elseif ($results['SKU_Basic'][$SKU]['availableCount'] / $results['SKU_Basic'][$SKU]['minimumCount'] * 100 -le $SKUCriticalThreshold_basic) { $critical = $true Add-Output -Output "<td class=red>$differenceCount</td>" } else { Add-Output -Output "<td class=yellow>$differenceCount</td>" } Add-Output -Output '</tr>' } Add-Output -Output ('</table></p> <p>The following criteria were used during the checkup:<ul> <li>Check products with >{0} total licenses</li> <li>Report normal products having both <{1} licenses and <{2}% of their total licenses available</li> <li>Report important products having both <{3} licenses and <{4}% of their total licenses available</li></ul></p>' -f $SKUIgnoreThreshold, $SKUTotalThreshold_normal, $SKUPercentageThreshold_normal, $SKUTotalThreshold_important, $SKUPercentageThreshold_important) } else { Add-Output -Output '<p>Nothing to report</p>' } # Output advanced SKU results Add-Output -Output '<p class=gray>Advanced checkup - Products</p>' if ($results.ContainsKey('SKU_Advanced')) { Add-Output -Output ('<p>Please check license counts for the following product SKUs and <a href="{0}">reserve</a> additional licenses:</p> <p><table> <tr><th>License type</th> <th>Enabled count</th> <th>Needed count</th> <th>Difference</th></tr>' -f $LicensingURL) foreach ($plan in $results['SKU_Advanced'].Keys) { $differenceCount = $results['SKU_Advanced'][$plan]['enabledCount'] - $results['SKU_Advanced'][$plan]['neededCount'] Add-Output -Output ('<tr><td>{0}</td><td>{1}</td><td>{2}</td>' -f $plan, $results['SKU_Advanced'][$plan]['enabledCount'], $results['SKU_Advanced'][$plan]['neededCount']) if ($results['SKU_Advanced'][$plan]['enabledCount'] / $results['SKU_Advanced'][$plan]['neededCount'] * 100 -ge $SKUWarningThreshold_advanced) { Add-Output -Output "<td class=green>$differenceCount</td>" } elseif ($results['SKU_Advanced'][$plan]['enabledCount'] / $results['SKU_Advanced'][$plan]['neededCount'] * 100 -le $SKUCriticalThreshold_advanced) { $critical = $true Add-Output -Output "<td class=red>$differenceCount</td>" } else { Add-Output -Output "<td class=yellow>$differenceCount</td>" } Add-Output -Output '</tr>' } Add-Output -Output '</table></p> <p>The following criteria were used during the checkup:<ul> <li>Check <em>Azure AD P1</em> based on groups using dynamic user membership</li> <li>Check <em>Azure AD P1</em> based on applications using group-based assignment</li> <li>Check <em>Azure AD P1/P2</em> based on users covered by Conditional Access</li> <li>Check <em>Azure AD P2</em> based on users eligible for Privileged Identity Management</li> <li>Check <em>Defender for Office 365 P1/P2</em> based on protected Exchange Online recipients</li> <li>Check <em>Intune Device</em> based on devices managed by Intune and used by unlicensed users</li></ul></p>' } else { Add-Output -Output '<p>Nothing to report</p>' } # Output basic user results Add-Output -Output '<p class=gray>Basic checkup - Users</p>' if ($results.ContainsKey('User_Basic')) { [decimal]$potentialSavings = 0 Add-Output -Output '<p>Please check license assignments for the following user accounts and mitigate impact:</p> <p><table> <tr><th>Account</th> <th>Interchangeable</th> <th>Optimizable</th> <th>Removable</th></tr>' foreach ($user in $results['User_Basic'] | Sort-Object UserPrincipalName) { if ($null -ne $SKUPrices) { if ($null -ne ($interchangeableSKUPrices = $SKUPrices | Where-Object{$_.SKUID -in $user.InterchangeableSKUIDs} | Sort-Object Price | Select-Object -Skip 1)) { $potentialSavings += ($interchangeableSKUPrices.Price | Measure-Object -Sum).Sum } if ($null -ne ($optimizableSKUPrices = $SKUPrices | Where-Object{$_.SKUID -in $user.OptimizableSKUIDs})) { $potentialSavings += ($optimizableSKUPrices.Price | Measure-Object -Sum).Sum } if ($null -ne ($removableSKUPrices = $SKUPrices | Where-Object{$_.SKUID -in $user.RemovableSKUIDs})) { $potentialSavings += ($removableSKUPrices.Price | Measure-Object -Sum).Sum } } Add-Output -Output ('<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td></tr>' -f $user.UserPrincipalName, (($user.InterchangeableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_} | Sort-Object) -join '<br>'), (($user.OptimizableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_} | Sort-Object) -join '<br>'), (($user.RemovableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_} | Sort-Object) -join '<br>')) } Add-Output -Output '</table></p>' if ($potentialSavings -gt 0) { Add-Output -Output ('<p>Potential savings when mitigating license assignment impact: {0:C}</p>' -f $potentialSavings) } Add-Output -Output '<p>The following criteria were used during the checkup:<ul> <li>Check accounts with any number of assigned licenses</li> <li>Report theoretically exclusive licenses as <strong>interchangeable</strong>, based on specified SKUs</li> <li>Report practically inclusive licenses as <strong>optimizable</strong>, based on available SKU features</li> <li>Report actually inclusive licenses as <strong>removable</strong>, based on enabled SKU features</li></ul></p>' } else { Add-Output -Output '<p>Nothing to report</p>' } # Output advanced user results Add-Output -Output '<p class=gray>Advanced checkup - Users</p>' if ($results.ContainsKey('User_Advanced')) { [decimal]$potentialSavings = 0 Add-Output -Output '<p>Please check license assignments for the following user accounts and mitigate impact:</p> <p><table> <tr><th>Account</th> <th>Preferable</th> <th>Replaceable</th></tr>' foreach ($user in $results['User_Advanced'] | Sort-Object UserPrincipalName) { if ($null -ne $SKUPrices) { if ($null -ne ($replaceableSKUPrices = $SKUPrices | Where-Object{$_.SKUID -in $user.ReplaceableSKUIDs})) { if ($null -ne ($preferableSKUPrice = $SKUPrices | Where-Object{$_.SKUID -eq $user.PreferableSKUID})) { $potentialSavings += ($replaceableSKUPrices.Price | Measure-Object -Sum).Sum - $preferableSKUPrice.Price } else { $potentialSavings += ($replaceableSKUPrices.Price | Measure-Object -Sum).Sum } } } Add-Output -Output ('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>' -f $user.UserPrincipalName, (Get-SKUName -SKUID $user.PreferableSKUID), (($user.ReplaceableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_} | Sort-Object) -join '<br>')) } Add-Output -Output '</table></p>' if ($potentialSavings -gt 0) { Add-Output -Output ('<p>Potential savings when mitigating license assignment impact: {0:C}</p>' -f $potentialSavings) } Add-Output -Output '<p>The following criteria were used during the checkup:</p> <p><table> <tr><th rowspan=2>Priority</th> <th rowspan=2 class=rule>License</th> <th colspan=4 class=rule>Account</th> <th colspan=1 class=rule>OneDrive</th> <th colspan=2 class=rule>Mailbox</th> <th colspan=4 class=rule>Apps</th></tr> <tr><th class=rule>Enabled</th> <th>Guest</th> <th>Created</th> <th>Active</th> <th class=rule>Storage</th> <th class=rule>Storage</th> <th>Archive</th> <th class=rule>Windows</th> <th>Mac</th> <th>Mobile</th> <th>Web</th></tr>' for ($i = 0; $i -lt $PreferableSKUs.Count; $i++) { $preferableSKU = $PreferableSKUs[$i] if ($preferableSKU.AccountEnabled -ne [SKURule]::AccountEnabledDefault()) { $ruleSetting_accountEnabled = $preferableSKU.AccountEnabled.ToUpper() } else { $ruleSetting_accountEnabled = '-' } if ($preferableSKU.AccountGuest -ne [SKURule]::AccountGuestDefault()) { $ruleSetting_accountGuest = $preferableSKU.AccountGuest.ToUpper() } else { $ruleSetting_accountGuest = '-' } if ($preferableSKU.CreatedEarlierThan -ne [SKURule]::CreatedEarlierThanDefault()) { $ruleSetting_createdEarlierThan = $preferableSKU.CreatedEarlierThan } else { $ruleSetting_createdEarlierThan = '-' } if ($preferableSKU.LastActiveEarlierThan -ne [SKURule]::LastActiveEarlierThanDefault()) { $ruleSetting_lastActiveEarlierThan = $preferableSKU.LastActiveEarlierThan } else { $ruleSetting_lastActiveEarlierThan = '-' } if ($preferableSKU.OneDriveGBUsedLessThan -ne [SKURule]::OneDriveGBUsedLessThanDefault()) { $ruleSetting_oneDriveGBUsedLessThan = $preferableSKU.OneDriveGBUsedLessThan } else { $ruleSetting_oneDriveGBUsedLessThan = '-' } if ($preferableSKU.MailboxGBUsedLessThan -ne [SKURule]::MailboxGBUsedLessThanDefault()) { $ruleSetting_mailboxGBUsedLessThan = $preferableSKU.MailboxGBUsedLessThan } else { $ruleSetting_mailboxGBUsedLessThan = '-' } if ($preferableSKU.MailboxHasArchive -ne [SKURule]::MailboxHasArchiveDefault()) { $ruleSetting_mailboxHasArchive = $preferableSKU.MailboxHasArchive.ToUpper() } else { $ruleSetting_mailboxHasArchive = '-' } if ($preferableSKU.WindowsAppUsed -ne [SKURule]::WindowsAppUsedDefault()) { $ruleSetting_windowsAppUsed = $preferableSKU.WindowsAppUsed.ToUpper() } else { $ruleSetting_windowsAppUsed = '-' } if ($preferableSKU.MacAppUsed -ne [SKURule]::MacAppUsedDefault()) { $ruleSetting_macAppUsed = $preferableSKU.MacAppUsed.ToUpper() } else { $ruleSetting_macAppUsed = '-' } if ($preferableSKU.MobileAppUsed -ne [SKURule]::MobileAppUsedDefault()) { $ruleSetting_mobileAppUsed = $preferableSKU.MobileAppUsed.ToUpper() } else { $ruleSetting_mobileAppUsed = '-' } if ($preferableSKU.WebAppUsed -ne [SKURule]::WebAppUsedDefault()) { $ruleSetting_webAppUsed = $preferableSKU.WebAppUsed.ToUpper() } else { $ruleSetting_webAppUsed = '-' } Add-Output -Output ('<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4:&l\t\;yyyy‑\;MM‑\;dd}</td><td>{5:&l\t\;yyyy‑\;MM‑\;dd}</td><td>{6:<\;0.# \;GB}</td><td>{7:<\;0.# \;GB}</td><td>{8}</td><td>{9}</td><td>{10}</td><td>{11}</td><td>{12}</td></tr>' -f ($i + 1), (Get-SKUName -SKUID $preferableSKU.SKUID), $ruleSetting_accountEnabled, $ruleSetting_accountGuest, $ruleSetting_createdEarlierThan, $ruleSetting_lastActiveEarlierThan, $ruleSetting_oneDriveGBUsedLessThan, $ruleSetting_mailboxGBUsedLessThan, $ruleSetting_mailboxHasArchive, $ruleSetting_windowsAppUsed, $ruleSetting_macAppUsed, $ruleSetting_mobileAppUsed, $ruleSetting_webAppUsed) } Add-Output -Output '</table></p>' } else { Add-Output -Output '<p>Nothing to report</p>' } # Configure and send email $email = @{ 'message' = @{ 'subject' = 'Azure AD licenses need attention' 'importance' = 'normal' 'body' = @{ 'contentType' = 'HTML' 'content' = $outputs.ToString() } } } $email['message'].Add('toRecipients', [System.Collections.Generic.List[hashtable]]::new()) foreach ($recipientAddress in $RecipientAddresses_normal) { $email['message']['toRecipients'].Add(@{ 'emailAddress' = @{ 'address' = $recipientAddress } }) } if ($critical) { $email['message']['subject'] = 'Azure AD licenses need urgent attention' $email['message']['importance'] = 'high' $email['message'].Add('ccRecipients', [System.Collections.Generic.List[hashtable]]::new()) foreach ($recipientAddress in $RecipientAddresses_critical) { $email['message']['ccRecipients'].Add(@{ 'emailAddress' = @{ 'address' = $recipientAddress } }) } } if ($null -ne $AttachmentFormat) { $email['message'].Add('attachments', [System.Collections.Generic.List[hashtable]]::new()) switch -Wildcard ($AttachmentFormat) { '*CSV' { if ($results.ContainsKey('User_Basic')) { switch ($AttachmentFormat) { 'CSV' { $csvOutput = $results['User_Basic'] | Select-Object UserPrincipalName, @{Name = 'InterchangeableSKUIDs'; Expression = {$_.InterchangeableSKUIDs -join ', '}}, @{Name = 'OptimizableSKUIDs'; Expression = {$_.OptimizableSKUIDs -join ', '}}, @{Name = 'RemovableSKUIDs'; Expression = {$_.RemovableSKUIDs -join ', '}} | ConvertTo-Csv -NoTypeInformation -Delimiter ';' } 'TranslatedCSV' { $csvOutput = $results['User_Basic'] | Select-Object UserPrincipalName, @{Name = 'InterchangeableSKUIDs'; Expression = {($_.InterchangeableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_}) -join ', '}}, @{Name = 'OptimizableSKUIDs'; Expression = {($_.OptimizableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_}) -join ', '}}, @{Name = 'RemovableSKUIDs'; Expression = {($_.RemovableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_}) -join ', '}} | ConvertTo-Csv -NoTypeInformation -Delimiter ';' } } $email['message']['attachments'].Add(@{ '@odata.type' = "#microsoft.graph.fileAttachment" 'name' = 'basic_results.csv' 'contentType' = 'text/csv' 'contentBytes' = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($csvOutput -join [System.Environment]::NewLine)) }) } if ($results.ContainsKey('User_Advanced')) { switch ($AttachmentFormat) { 'CSV' { $csvOutput = $results['User_Advanced'] | Select-Object UserPrincipalName, PreferableSKUID, @{Name = 'ReplaceableSKUIDs'; Expression = {$_.ReplaceableSKUIDs -join ', '}} | ConvertTo-Csv -NoTypeInformation -Delimiter ';' } 'TranslatedCSV' { $csvOutput = $results['User_Advanced'] | Select-Object UserPrincipalName, @{Name = 'PreferableSKUID'; Expression = {Get-SKUName -SKUID $_.PreferableSKUID}}, @{Name = 'ReplaceableSKUIDs'; Expression = {($_.ReplaceableSKUIDs | ForEach-Object{Get-SKUName -SKUID $_}) -join ', '}} | ConvertTo-Csv -NoTypeInformation -Delimiter ';' } } $email['message']['attachments'].Add(@{ '@odata.type' = "#microsoft.graph.fileAttachment" 'name' = 'advanced_results.csv' 'contentType' = 'text/csv' 'contentBytes' = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($csvOutput -join [System.Environment]::NewLine)) }) } } 'JSON' { $jsonOutput = @{ 'basic_results' = $results['User_Basic'] 'advanced_results' = $results['User_Advanced'] } | ConvertTo-Json $email['message']['attachments'].Add(@{ '@odata.type' = "#microsoft.graph.fileAttachment" 'name' = 'results.json' 'contentType' = 'application/json' 'contentBytes' = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($jsonOutput)) }) } } } Invoke-MgGraphRequest -Method POST -Uri ('https://graph.microsoft.com/v1.0/users/{0}/sendMail' -f $SenderAddress) -Body $email -ContentType 'application/json' } #endregion Disconnect-MgGraph | Out-Null } } |