From cd6b9b87cda354d4654febc3daf444fddda7460a Mon Sep 17 00:00:00 2001 From: IxianPixel Date: Mon, 18 Mar 2024 13:43:27 +0000 Subject: [PATCH] Added Entra scripts --- Entra/Get-PasswordExpiryReport.ps1 | 23 ++ Entra/Get-ReportUserAssignedLicenses.PS1 | 478 +++++++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 Entra/Get-PasswordExpiryReport.ps1 create mode 100644 Entra/Get-ReportUserAssignedLicenses.PS1 diff --git a/Entra/Get-PasswordExpiryReport.ps1 b/Entra/Get-PasswordExpiryReport.ps1 new file mode 100644 index 0000000..b0ea8a3 --- /dev/null +++ b/Entra/Get-PasswordExpiryReport.ps1 @@ -0,0 +1,23 @@ +#Connect to Microsoft Graph +Connect-MgGraph -Scopes "User.Read.All" + +#Set the properties to retrieve +$Properties = @( + "id", + "DisplayName", + "userprincipalname", + "PasswordPolicies", + "AccountEnabled", + "UserType", + "OnPremisesSyncEnabled", + "lastPasswordChangeDateTime", + "mail", + "jobtitle", + "department" + ) + +#Retrieve the password change date timestamp of all users +$AllUsers = Get-MgUser -All -Property $Properties | Select-Object -Property $Properties + +#Export to CSV +$AllUsers | Export-Csv -Path "C:\Temp\PasswordChangeTimeStamp.csv" -NoTypeInformation \ No newline at end of file diff --git a/Entra/Get-ReportUserAssignedLicenses.PS1 b/Entra/Get-ReportUserAssignedLicenses.PS1 new file mode 100644 index 0000000..8602a3c --- /dev/null +++ b/Entra/Get-ReportUserAssignedLicenses.PS1 @@ -0,0 +1,478 @@ +# ReportUserAssignedLicenses-MgGraph.PS1 +# Create a report of licenses assigned to Azure AD user accounts using the Microsoft Graph PowerShell SDK cmdlets +# https://github.com/12Knocksinna/Office365itpros/blob/master/ReportUserAssignedLicenses-MgGraph.PS1 +# See https://practical365.com/create-licensing-report-microsoft365-tenant/ for an article describing how to run the report and +# https://practical365.com/report-user-license-costs/ for information about how to include licensing cost information +# in the output + +# V1.1 27-Sep-2022 Add sign in data for users and calculate how long it's been since they signed in and used a license. +# V1.2 23-Nov-2022 Added SKU usage summary to HTML report +# V1.3 29-Sep-2023 Added support for group-based licensing +# V1.4 13-Oct-2023 Fixed some bugs +# V1.5 26-Jan-2024 Added license pricing computation +# V1.5 8-Feb-2024 Added cost analysis for departments and countries +# V1.6 12-Feb-2024 Added info to report when license costs can't be attributed to countries or departments because of missing user account properties +# V1.7 7-Mar-2024 Added company name to the set of properties output by report + +Function Get-LicenseCosts { + # Function to calculate the annual costs of the licenses assigned to a user account + [cmdletbinding()] + Param( [array]$Licenses ) + [int]$Costs = 0 + ForEach ($License in $Licenses) { + Try { + [string]$LicenseCost = $PricingHashTable[$License] + # Monthly cost in cents (because some licenses cost sums like 16.40) + [float]$LicenseCostCents = [float]$LicenseCost * 100 + If ($LicenseCostCents -gt 0) { + # Compute annual cost for the license + [float]$AnnualCost = $LicenseCostCents * 12 + # Add to the cumulative license costs + $Costs = $Costs + ($AnnualCost) + # Write-Host ("License {0} Cost {1} running total {2}" -f $License, $LicenseCost, $Costs) + } + } + Catch { + Write-Host ("Error finding license {0} in pricing table - please check" -f $License) + } + } + # Return + Return ($Costs / 100) +} + +[datetime]$RunDate = Get-Date -format "dd-MMM-yyyy HH:mm:ss" +$Version = "1.7" +$CSVOutputFile = "c:\temp\Microsoft365LicensesReport.CSV" +$ReportFile = "c:\temp\Microsoft365LicensesReport.html" +$UnlicensedAccounts = 0 +# Default currency - can be overwritten by a value read into the $ImportSkus array +[string]$Currency = "USD" + +# Connect to the Graph, specifing the tenant and profile to use - Add your tenant identifier here +Connect-MgGraph -Scope "Directory.AccessAsUser.All, Directory.Read.All, AuditLog.Read.All" -NoWelcome + +<# +Alternative: Use Application ID and Secured Password for authentication (you could also pass a certificate thumbnail) +$ApplicationId = "" +$SecuredPassword = "" +$tenantID = "" + +$SecuredPasswordPassword = ConvertTo-SecureString -String $SecuredPassword -AsPlainText -Force +$ClientSecretCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $ApplicationId, $SecuredPasswordPassword +Connect-MgGraph -TenantId $tenantID -ClientSecretCredential $ClientSecretCredential +#> + +# This step depends on the availability of some CSV files generated to hold information about the product licenses used in the tenant and +# the service plans in those licenses. See https://github.com/12Knocksinna/Office365itpros/blob/master/CreateCSVFilesForSKUsAndServicePlans.PS1 +# for code to generate the CSVs. After the files are created, you need to edit them to add the display names for the SKUs and plans. +# Build Hash of Skus for lookup so that we report user-friendly display names - you need to create these CSV files from SKU and service plan +# data in your tenant. + +$skuDataPath = "C:\temp\SkuDataComplete.csv" +$servicePlanPath = "C:\temp\ServicePlanDataComplete.csv" + +If ((Test-Path $skuDataPath) -eq $False) { + Write-Host ("Can't find the product data file ({0}). Exiting..." -f $skuDataPath) ; break +} +If ((Test-Path $servicePlanPath) -eq $False) { + Write-Host ("Can't find the serivice plan data file ({0}). Exiting..." -f $servicePlanPath) ; break +} + +$ImportSkus = Import-CSV $skuDataPath +$ImportServicePlans = Import-CSV $servicePlanPath +$SkuHashTable = @{} +ForEach ($Line in $ImportSkus) { $SkuHashTable.Add([string]$Line.SkuId, [string]$Line.DisplayName) } +$ServicePlanHashTable = @{} +ForEach ($Line2 in $ImportServicePlans) { $ServicePlanHashTable.Add([string]$Line2.ServicePlanId, [string]$Line2.ServicePlanDisplayName) } + +# If pricing information is in the $ImportSkus array, we can add the information to the report. We prepare to do this +# by setting the $PricingInfoAvailable to $true and populating the $PricingHashTable +$PricingInfoAvailable = $false + +If ($ImportSkus[0].Price) { + $PricingInfoAvailable = $true + $Global:PricingHashTable = @{} + ForEach ($Line in $ImportSkus) { + $PricingHashTable.Add([string]$Line.SkuId, [string]$Line.Price) + } + If ($ImportSkus[0].Currency) { + [string]$Currency = ($ImportSkus[0].Currency) + } +} + +# Find tenant accounts - but filtered so that we only fetch those with licenses +Write-Host "Finding licensed user accounts..." +[Array]$Users = Get-MgUser -Filter "assignedLicenses/`$count ne 0 and userType eq 'Member'" ` + -ConsistencyLevel eventual -CountVariable Records -All ` + -Property id, displayName, userPrincipalName, country, department, assignedlicenses, ` + licenseAssignmentStates, createdDateTime, jobTitle, signInActivity, companyName | ` + Sort-Object DisplayName + +If (!($Users)) { + Write-Host "No licensed user accounts found - exiting"; break +} +Else { + Write-Host ("{0} Licensed user accounts found - now processing their license data..." -f $Users.Count) +} + +[array]$Departments = $Users.Department | Sort-Object -Unique +[array]$Countries = $Users.Country | Sort-Object -Unique +$OrgName = (Get-MgOrganization).DisplayName +$DuplicateSKUsAccounts = 0; $DuplicateSKULicenses = 0; $LicenseErrorCount = 0 +$Report = [System.Collections.Generic.List[Object]]::new() +$i = 0 +[float]$TotalUserLicenseCosts = 0 +[float]$TotalBoughtLicenseCosts = 0 + +ForEach ($User in $Users) { + $UnusedAccountWarning = "OK"; $i++; $UserCosts = 0 + $ErrorMsg = ""; $LastLicenseChange = "" + Write-Host ("Processing account {0} {1}/{2}" -f $User.UserPrincipalName, $i, $Users.Count) + If ([string]::IsNullOrWhiteSpace($User.licenseAssignmentStates) -eq $False) { + # Only process account if it has some licenses + [array]$LicenseInfo = $Null; [array]$DisabledPlans = $Null; + # Find out if any of the user's licenses are assigned via group-based licensing + [array]$GroupAssignments = $User.licenseAssignmentStates | ` + Where-Object { $null -ne $_.AssignedByGroup -and $_.State -eq "Active" } + # Find out if any of the user's licenses are assigned via group-based licensing and having an error + [array]$GroupErrorAssignments = $User.licenseAssignmentStates | ` + Where-Object { $Null -ne $_.AssignedByGroup -and $_.State -eq "Error" } + [array]$GroupLicensing = $Null + # Find out when the last license change was made + If ([string]::IsNullOrWhiteSpace($User.licenseAssignmentStates.lastupdateddatetime) -eq $False) { + $LastLicenseChange = Get-Date(($user.LicenseAssignmentStates.lastupdateddatetime | Measure-Object -Maximum).Maximum) -format g + } + # Figure out group-based licensing assignments if any exist + ForEach ($G in $GroupAssignments) { + $GroupName = (Get-MgGroup -GroupId $G.AssignedByGroup).DisplayName + $GroupProductName = $SkuHashTable[$G.SkuId] + $GroupLicensing += ("{0} assigned from {1}" -f $GroupProductName, $GroupName) + } + ForEach ($G in $GroupErrorAssignments) { + $GroupName = (Get-MgGroup -GroupId $G.AssignedByGroup).DisplayName + $GroupProductName = $SkuHashTable[$G.SkuId] + $ErrorMsg = $G.Error + $LicenseErrorCount++ + $GroupLicensing += ("{0} assigned from {1} BUT ERROR {2}!" -f $GroupProductName, $GroupName, $ErrorMsg) + } + $GroupLicensingAssignments = $GroupLicensing -Join ", " + + # Find out if any of the user's licenses are assigned via direct licensing + [array]$DirectAssignments = $User.licenseAssignmentStates | ` + Where-Object { $null -eq $_.AssignedByGroup -and $_.State -eq "Active" } + + # Figure out details of direct assigned licenses + [array]$UserLicenses = $User.AssignedLicenses + ForEach ($License in $DirectAssignments) { + If ($SkuHashTable.ContainsKey($License.SkuId) -eq $True) { + # We found a match in the SKU hash table + $LicenseInfo += $SkuHashTable.Item($License.SkuId) + } Else { + # Nothing found in the SKU hash table, so output the SkuID + $LicenseInfo += $License.SkuId + } + } + + # Report any disabled service plans in licenses + $License = $UserLicenses | Where-Object { -not [string]::IsNullOrWhiteSpace($_.DisabledPlans) } + # Check if disabled service plans in a license + ForEach ($DisabledPlan in $License.DisabledPlans) { + # Try and find what service plan is disabled + If ($ServicePlanHashTable.ContainsKey($DisabledPlan) -eq $True) { + # We found a match in the Service Plans hash table + $DisabledPlans += $ServicePlanHashTable.Item($DisabledPlan) + } + Else { + # Nothing doing, so output the Service Plan ID + $DisabledPlans += $DisabledPlan + } + } # End ForEach disabled plans + + # Detect if any duplicate licenses are assigned (direct and group-based) + # Build a list of assigned SKUs + $SkuUserReport = [System.Collections.Generic.List[Object]]::new() + ForEach ($S in $DirectAssignments) { + $ReportLine = [PSCustomObject][Ordered]@{ + User = $User.Id + Name = $User.DisplayName + Sku = $S.SkuId + Method = "Direct" + } + $SkuUserReport.Add($ReportLine) + } + ForEach ($S in $GroupAssignments) { + $ReportLine = [PSCustomObject][Ordered]@{ + User = $User.Id + Name = $User.DisplayName + Sku = $S.SkuId + Method = "Group" + } + $SkuUserReport.Add($ReportLine) + } + + # Check if any duplicates exist + [array]$DuplicateSkus = $SkuUserReport | Group-Object Sku | ` + Where-Object { $_.Count -gt 1 } | Select-Object -ExpandProperty Name + + # If duplicates exist, resolve their SKU IDs into Product names and generate a warning for the report + [string]$DuplicateWarningReport = "N/A" + If ($DuplicateSkus) { + [array]$DuplicateSkuNames = $Null + $DuplicateSKUsAccounts++ + $DuplicateSKULicenses = $DuplicateSKULicenses + $DuplicateSKUs.Count + ForEach ($DS in $DuplicateSkus) { + $SkuName = $SkuHashTable[$DS] + $DuplicateSkuNames += $SkuName + } + $DuplicateWarningReport = ("Warning: Duplicate licenses detected for: {0}" -f ($DuplicateSkuNames -join ", ")) + } + } Else { + $UnlicensedAccounts++ + } + + $LastSignIn = $User.SignInActivity.LastSignInDateTime + $LastNonInteractiveSignIn = $User.SignInActivity.LastNonInteractiveSignInDateTime + + if (-not $LastSignIn -and -not $LastNonInteractiveSignIn) { + $DaysSinceLastSignIn = "Unknown" + $UnusedAccountWarning = ("Unknown last sign-in for account") + $LastAccess = "Unknown" + } + else { + # Get the newest date, if both dates contain values + if ($LastSignIn -and $LastNonInteractiveSignIn) { + if ($LastSignIn -gt $LastNonInteractiveSignIn) { + $CompareDate = $LastSignIn + } + else { + $CompareDate = $LastNonInteractiveSignIn + } + } + elseif ($LastSignIn) { + # Only $LastSignIn has a value + $CompareDate = $LastSignIn + } + else { + # Only $LastNonInteractiveSignIn has a value + $CompareDate = $LastNonInteractiveSignIn + } + + $DaysSinceLastSignIn = ($RunDate - $CompareDate).Days + $LastAccess = Get-Date($CompareDate) -format g + If ($DaysSinceLastSignIn -gt 60) { + $UnusedAccountWarning = ("Account unused for {0} days - check!" -f $DaysSinceLastSignIn) + } + } + + $AccountCreatedDate = $Null + If ($User.CreatedDateTime) { + $AccountCreatedDate = Get-Date($User.CreatedDateTime) -format 'dd-MMM-yyyy HH:mm' + } + + # Report information + [string]$DisabledPlans = $DisabledPlans -join ", " + [string]$LicenseInfo = $LicenseInfo -join (", ") + + If ($PricingInfoAvailable) { + # Output report line with pricing info + [float]$UserCosts = Get-LicenseCosts -Licenses $UserLicenses.SkuId + $TotalUserLicenseCosts = $TotalUserLicenseCosts + $UserCosts + $ReportLine = [PSCustomObject][Ordered]@{ + User = $User.DisplayName + UPN = $User.UserPrincipalName + Country = $User.Country + Department = $User.Department + Title = $User.JobTitle + Company = $User.companyName + "Direct assigned licenses" = $LicenseInfo + "Disabled Plans" = $DisabledPlans + "Group based licenses" = $GroupLicensingAssignments + "Annual License Costs" = ("{0} {1}" -f $Currency, ($UserCosts.toString('F2'))) + "Error message" = $ErrorMsg + "Last license change" = $LastLicenseChange + "Account created" = $AccountCreatedDate + "Last Signin" = $LastAccess + "Days since last signin" = $DaysSinceLastSignIn + "Duplicates detected" = $DuplicateWarningReport + Status = $UnusedAccountWarning + UserCosts = $UserCosts + } + } + Else { + # No pricing information + $ReportLine = [PSCustomObject][Ordered]@{ + User = $User.DisplayName + UPN = $User.UserPrincipalName + Country = $User.Country + Department = $User.Department + Title = $User.JobTitle + Company = $User.companyName + "Direct assigned licenses" = $LicenseInfo + "Disabled Plans" = $DisabledPlans + "Group based licenses" = $GroupLicensingAssignments + "Error message" = $ErrorMsg + "Last license change" = $LastLicenseChange + "Account created" = $AccountCreatedDate + "Last Signin" = $LastAccess + "Days since last signin" = $DaysSinceLastSignIn + "Duplicates detected" = $DuplicateWarningReport + Status = $UnusedAccountWarning + } + } + $Report.Add($ReportLine) +} # End ForEach Users + +$UnderusedAccounts = $Report | Where-Object { $_.Status -ne "OK" } +$PercentUnderusedAccounts = ($UnderUsedAccounts.Count / $Report.Count).toString("P") + +# This code grabs the SKU summary for the tenant and uses the data to create a SKU summary usage segment for the HTML report +$SkuReport = [System.Collections.Generic.List[Object]]::new() +[array]$SkuSummary = Get-MgSubscribedSku | Select-Object SkuId, ConsumedUnits, PrepaidUnits +$SkuSummary = $SkuSummary | Where-Object { $_.ConsumedUnits -ne 0 } +ForEach ($S in $SkuSummary) { + $SKUCost = Get-LicenseCosts -Licenses $S.SkuId + $SkuDisplayName = $SkuHashtable[$S.SkuId] + If ($S.PrepaidUnits.Enabled -le $S.ConsumedUnits ) { + $BoughtUnits = $S.ConsumedUnits + } + Else { + $BoughtUnits = $S.PrepaidUnits.Enabled + } + If ($PricingInfoAvailable) { + $SKUTotalCost = ($SKUCost * $BoughtUnits) + $SkuReportLine = [PSCustomObject][Ordered]@{ + "SKU Id" = $S.SkuId + "SKU Name" = $SkuDisplayName + "Units Used" = $S.ConsumedUnits + "Units Purchased" = $BoughtUnits + "Annual license costs" = $SKUTotalCost + "Annual licensing cost" = ("{0} {1}" -f $Currency, ('{0:N2}' -f $SKUTotalCost)) + } + } + Else { + $SkuReportLine = [PSCustomObject][Ordered]@{ + "SKU Id" = $S.SkuId + "SKU Name" = $SkuDisplayName + "Units Used" = $S.ConsumedUnits + "Units Purchased" = $BoughtUnits + } + } + $SkuReport.Add($SkuReportLine) + $TotalBoughtLicenseCosts = $TotalBoughtLicenseCosts + $SKUTotalCost +} + +If ($PricingInfoAvailable) { + $AverageCostPerUser = ($TotalUserLicenseCosts / $Users.Count) + $AverageCostPerUserOutput = ("{0} {1}" -f $Currency, ('{0:N2}' -f $AverageCostPerUser)) + $TotalUserLicenseCostsOutput = ("{0} {1}" -f $Currency, ('{0:N2}' -f $TotalUserLicenseCosts)) + $TotalBoughtLicenseCostsOutput = ("{0} {1}" -f $Currency, ('{0:N2}' -f $TotalBoughtLicenseCosts)) + $PercentBoughtLicensesUsed = ($TotalUserLicenseCosts / $TotalBoughtLicenseCosts).toString('P') + $SkuReport = $SkuReport | Sort-Object "Annual license costs" -Descending +} Else { + $SkuReport = $SkuReport | Sort-Object "SKU Name" -Descending +} + +If ($PricingInfoAvailable) { + # Generate the department analysis + $DepartmentReport = [System.Collections.Generic.List[Object]]::new() + ForEach ($Department in $Departments) { + $DepartmentRecords = $Report | Where-Object Department -match $Department + $DepartmentReportLine = [PSCustomObject][Ordered]@{ + Department = $Department + Accounts = $DepartmentRecords.count + Costs = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($DepartmentRecords | Measure-Object UserCosts -Sum).Sum)) + AverageCost = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($DepartmentRecords | Measure-Object UserCosts -Average).Average)) + } + $DepartmentReport.Add($DepartmentReportLine) + } + $DepartmentHTML = $DepartmentReport | ConvertTo-HTML -Fragment + # Anyone without a department? + [array]$NoDepartments = $Report | Where-Object { $null -eq $_.Department } + $NoDepartmentCosts = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($NoDepartments | Measure-Object UserCosts -Sum).Sum)) + + # Generate the country analysis + $CountryReport = [System.Collections.Generic.List[Object]]::new() + ForEach ($Country in $Countries) { + $CountryRecords = $Report | Where-Object Country -match $Country + $CountryReportLine = [PSCustomObject][Ordered]@{ + Country = $Country + Accounts = $CountryRecords.count + Costs = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($CountryRecords | Measure-Object UserCosts -Sum).Sum)) + AverageCost = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($CountryRecords | Measure-Object UserCosts -Average).Average)) + } + $CountryReport.Add($CountryReportLine) + } + $CountryHTML = $CountryReport | ConvertTo-HTML -Fragment + # Anyone without a country? + [array]$NoCountry = $Report | Where-Object { $null -eq $_.Country } + $NoCountryCosts = ("{0} {1}" -f $Currency, ('{0:N2}' -f ($NoCountry | Measure-Object UserCosts -Sum).Sum)) +} + +# Create the HTML report +$HtmlHead = " + + +
+

Microsoft 365 License Report

+

For the " + $Orgname + " tenant

+

Generated: " + $RunDate + "

" + +$HtmlBody1 = $Report | ConvertTo-Html -Fragment +$HtmlBody1 = $HTMLBody1 + "

Report created for: " + $OrgName + "

" + +"

Created: " + $RunDate + "

" + +"

-----------------------------------------------------------------------------------------------------------------------------

" + +"

Number of licensed user accounts found: " + $Report.Count + "

" + +"

Number of underused user accounts found: " + $UnderUsedAccounts.Count + "

" + +"

Percent underused user accounts: " + $PercentUnderusedAccounts + "

" + +"

Accounts detected with duplicate licenses: " + $DuplicateSKUsAccounts + "

" + +"

Count of duplicate licenses: " + $DuplicateSKULicenses + "

" + +"

Count of errors: " + $LicenseErrorCount + "

" + +"

-----------------------------------------------------------------------------------------------------------------------------

" + + +$HtmlBody2 = $SkuReport | Select-Object "SKU Id", "SKU Name", "Units used", "Units purchased", "Annual licensing cost" | ConvertTo-Html -Fragment +$HtmlSkuSeparator = "

Product License Distribution

" + +$HtmlTail = "

" +# Add Cost analysis if pricing information is available + +If ($PricingInfoAvailable) { + $HTMLTail = $HTMLTail + "

Licensing Cost Analysis

" + + "

Total licensing cost for tenant: " + $TotalBoughtLicenseCostsOutput + "

" + + "

Total cost for assigned licenses: " + $TotalUserLicenseCostsOutput + "

" + + "

Percent bought licenses assigned to users: " + $PercentBoughtLicensesUsed + "

" + + "

Average licensing cost per user: " + $AverageCostPerUserOutput + "

" + + "

License Costs by Country

" + $CountryHTML + + "

License costs for users without a country: " + $NoCountryCosts + + "

License Costs by Department

" + $DepartmentHTML + + "

License costs for users without a department: " + $NoDepartmentCosts +} + +$HTMLTail = $HTMLTail + "

Microsoft 365 Licensing Report " + $Version + "

" + +$HtmlReport = $Htmlhead + $Htmlbody1 + $HtmlSkuSeparator + $HtmlBody2 + $Htmltail +$HtmlReport | Out-File $ReportFile -Encoding UTF8 + +$Report | Export-CSV -NoTypeInformation $CSVOutputFile +Write-Host "" +Write-Host "All done. Output files are" $CSVOutputFile "and" $ReportFile + +Disconnect-MgGraph + +# An example script used to illustrate a concept. More information about the topic can be found in the Office 365 for IT Pros eBook https://gum.co/O365IT/ +# and/or a relevant article on https://office365itpros.com or https://www.practical365.com. See our post about the Office 365 for IT Pros repository # https://office365itpros.com/office-365-github-repository/ for information about the scripts we write. + +# Do not use our scripts in production until you are satisfied that the code meets the need of your organization. Never run any code downloaded from the Internet without +# first validating the code in a non-production environment.