From 2889c5969c495d4f4ad570eecdaa2e3dc18ecb08 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 21:41:51 +0200 Subject: [PATCH 01/11] feat: add shared HTML report template with rksolutions.nl brand New-RKSolutionsReportTemplate provides the full HTML shell (head, CSS, JS, brand elements) for all reports. Implements the editorial parchment brand with light (chalk-white) and dark (warm parchment) themes, Playfair Display/Source Serif 4/JetBrains Mono typography, breadcrumb pill, eyebrow, stat tiles, status badges, and PS prompt footer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-05-03-report-rebrand-integration.md | 205 +++++ ...05-03-report-integration-rebrand-design.md | 268 ++++++ .../Private/New-RKSolutionsReportTemplate.ps1 | 797 ++++++++++++++++++ module/RKSolutions.psm1 | 3 +- 4 files changed, 1272 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-03-report-rebrand-integration.md create mode 100644 docs/superpowers/specs/2026-05-03-report-integration-rebrand-design.md create mode 100644 module/Private/New-RKSolutionsReportTemplate.ps1 diff --git a/docs/superpowers/plans/2026-05-03-report-rebrand-integration.md b/docs/superpowers/plans/2026-05-03-report-rebrand-integration.md new file mode 100644 index 0000000..fb93e2a --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-report-rebrand-integration.md @@ -0,0 +1,205 @@ +# Report Rebrand + Custom Security Attributes Integration + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a shared HTML report template with rksolutions.nl branding, integrate Get-CustomSecurityAttributesReport, and migrate all existing reports to the shared template. + +**Architecture:** A single `New-RKSolutionsReportTemplate` PowerShell function provides the full HTML shell (head, CSS, JS, brand elements). Each report only builds its unique content (stats cards, tables, filters) and passes it to the template. The new Custom Security Attributes report is built on the template from the start; existing reports are migrated one at a time. + +**Tech Stack:** PowerShell 7.0+, Microsoft Graph API, HTML5/CSS3/JS, Bootstrap 5, DataTables, Google Fonts (Playfair Display, Source Serif 4, JetBrains Mono) + +**Spec:** `docs/superpowers/specs/2026-05-03-report-integration-rebrand-design.md` + +--- + +### Task 1: Create Shared HTML Report Template + +**Files:** +- Create: `module/Private/New-RKSolutionsReportTemplate.ps1` + +This is the foundation. Every subsequent task depends on it. + +- [ ] **Step 1: Create `New-RKSolutionsReportTemplate.ps1`** + +The function accepts: `$TenantName`, `$ReportTitle`, `$ReportSlug`, `$Eyebrow`, `$Lede`, `$StatsCardsHtml`, `$BodyContentHtml`, `$CustomCss`, `$ReportDate`, `$Tags`. + +Returns a complete HTML document string via expandable here-string with: +- Google Fonts + CDN links (Bootstrap 5, DataTables, Font Awesome, jQuery, jszip, pdfmake) +- CSS custom properties for light (`:root`) and dark (`[data-theme="dark"]`) themes using the validated brand tokens from the spec +- Shared CSS: typography (Playfair Display for titles, Source Serif 4 for body, JetBrains Mono for monospace), breadcrumb pill, eyebrow line, title block, stat tile colors (rust/olive/steel/rose), filter bar, table styles, status badges, tag pills, theme toggle, DataTables overrides, PS prompt footer +- `$CustomCss` injected inside ` + + + + +
+ + + +
+ +
+ +
+ cd ./reports/$ReportSlug +
+ + +
$Eyebrow · GENERATED $ReportDate
+ + +

$TenantName $ReportTitle Report

+ + + $ledeHtml + + + $tagPillsHtml + + +
+ + + $statsHtml + + + $BodyContentHtml + +
+ + + + + + + + +"@ + + return $html +} diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 5057b84..4e3ca30 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -31,7 +31,8 @@ if (Test-Path $privatePath) { 'Invoke-RKSolutionsWithConnection.ps1', 'Invoke-GraphRequestWithPaging.ps1', 'Send-EmailWithAttachment.ps1', - 'ConvertTo-DateString.ps1' + 'ConvertTo-DateString.ps1', + 'New-RKSolutionsReportTemplate.ps1' ) foreach ($name in $sharedOrder) { $fp = Join-Path $privatePath $name From b3d99ec26ccf0827d86d55fe4c9943f276ddcab5 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 21:43:22 +0200 Subject: [PATCH 02/11] feat: add Get-CustomSecurityAttributesReport cmdlet Integrates the standalone Custom Security Attributes report into the module. New public cmdlet with AttributeSet/AttributeNames/Filters parameters, private helper for Graph data fetching and HTML generation using the shared brand template. Adds CustomSecAttributeAssignment.Read.All scope to Connect-RKGraph defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/CustomSecurityAttributes.ps1 | 271 ++++++++++++++++++ module/Public/Connect-RKGraph.ps1 | 3 +- .../Get-CustomSecurityAttributesReport.ps1 | 106 +++++++ module/RKSolutions.psm1 | 5 +- 4 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 module/Private/CustomSecurityAttributes.ps1 create mode 100644 module/Public/Get-CustomSecurityAttributesReport.ps1 diff --git a/module/Private/CustomSecurityAttributes.ps1 b/module/Private/CustomSecurityAttributes.ps1 new file mode 100644 index 0000000..ec906ac --- /dev/null +++ b/module/Private/CustomSecurityAttributes.ps1 @@ -0,0 +1,271 @@ +# Custom Security Attributes - Private helpers + +function Get-CustomSecurityAttributeData { + param( + [Parameter(Mandatory = $true)] + [string]$AttributeSet, + + [Parameter(Mandatory = $false)] + [string[]]$AttributeNames, + + [Parameter(Mandatory = $false)] + [hashtable]$Filters, + + [Parameter(Mandatory = $false)] + [switch]$DebugMode + ) + + Write-Host "Retrieving attribute definitions for '$AttributeSet'..." -ForegroundColor Cyan + $attributeFilter = [uri]::EscapeDataString("attributeSet eq '$AttributeSet'") + $attrDefUri = "https://graph.microsoft.com/beta/directory/customSecurityAttributeDefinitions?`$filter=$attributeFilter" + $attributeDefinitions = Invoke-MgGraphRequest -Method GET -Uri $attrDefUri -OutputType PSObject + + if ($attributeDefinitions.value.Count -eq 0) { + throw "No custom security attribute definitions found for attribute set '$AttributeSet'" + } + + # If AttributeNames not specified, use all attributes from the set + if (-not $AttributeNames) { + $AttributeNames = $attributeDefinitions.value | ForEach-Object { $_.name } + Write-Host "Using all attributes: $($AttributeNames -join ', ')" -ForegroundColor Yellow + } else { + Write-Host "Selected attributes: $($AttributeNames -join ', ')" -ForegroundColor Cyan + } + + # Build filter query + $filterConditions = @() + if ($Filters) { + foreach ($key in $Filters.Keys) { + $value = $Filters[$key] + $filterConditions += "customSecurityAttributes/$AttributeSet/$key eq '$value'" + } + } + + if ($filterConditions.Count -eq 0) { + $firstAttr = $AttributeNames[0] + $filterQuery = "customSecurityAttributes/$AttributeSet/$firstAttr ne null" + } else { + $filterQuery = $filterConditions -join ' and ' + } + + # Query users + $uri = "https://graph.microsoft.com/v1.0/users?`$filter=$filterQuery&`$count=true&`$select=id,displayName,userPrincipalName,customSecurityAttributes" + + Write-Host 'Querying users with custom security attributes...' -ForegroundColor Cyan + $results = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers @{ + ConsistencyLevel = 'eventual' + } -OutputType PSObject + + # Handle paging + $allUsers = [System.Collections.Generic.List[object]]::new() + if ($results.value) { $allUsers.AddRange($results.value) } + while ($results.'@odata.nextLink') { + $results = Invoke-MgGraphRequest -Method GET -Uri $results.'@odata.nextLink' -Headers @{ ConsistencyLevel = 'eventual' } -OutputType PSObject + if ($results.value) { $allUsers.AddRange($results.value) } + } + + # Process results + $userData = @() + foreach ($user in $allUsers) { + $attributeData = $user.customSecurityAttributes.$AttributeSet + + $userObject = [ordered]@{ + DisplayName = $user.displayName + UserPrincipalName = $user.userPrincipalName + } + + foreach ($attrName in $AttributeNames) { + $userObject[$attrName] = if ($attributeData.$attrName) { $attributeData.$attrName } else { '-' } + } + + $userObject['UserId'] = $user.id + $userData += [PSCustomObject]$userObject + } + + return @{ + UserData = $userData + AttributeNames = $AttributeNames + } +} + +function New-CustomSecurityAttributesHTMLReport { + param( + [Parameter(Mandatory = $true)] + [string]$TenantName, + + [Parameter(Mandatory = $true)] + [array]$UserData, + + [Parameter(Mandatory = $true)] + [string]$AttributeSet, + + [Parameter(Mandatory = $true)] + [string[]]$AttributeNames, + + [Parameter(Mandatory = $false)] + [string]$ExportPath + ) + + if (-not $ExportPath) { + $ExportPath = Join-Path (Get-Location).Path "$TenantName-CustomSecurityAttributes.html" + } + + $totalUsers = $UserData.Count + $reportDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + + # Calculate unique counts per attribute + $attributeStats = @{} + foreach ($attrName in $AttributeNames) { + $uniqueCount = ($UserData | Select-Object -ExpandProperty $attrName -Unique | Where-Object { $_ -ne '-' }).Count + $attributeStats[$attrName] = $uniqueCount + } + + # Build stat tiles + $tileColors = @('t-rust', 't-olive', 't-steel', 't-rose') + $statsCardsHtml = @" +
+
TOTAL USERS
+
$totalUsers
+
With $AttributeSet attributes
+
+"@ + $colorIdx = 1 + foreach ($attrName in ($AttributeNames | Select-Object -First 3)) { + $count = $attributeStats[$attrName] + $color = $tileColors[$colorIdx] + $statsCardsHtml += @" + +
+
$($attrName.ToUpper())
+
$count
+
Unique values
+
+"@ + $colorIdx++ + } + + # Build filter dropdowns + $colSize = [Math]::Max(12 / [Math]::Min($AttributeNames.Count, 4), 3) + $filterDropdownsHtml = '' + foreach ($attrName in $AttributeNames) { + $uniqueValues = $UserData | Select-Object -ExpandProperty $attrName -Unique | Where-Object { $_ -ne '-' } | Sort-Object + $optionsHtml = ($uniqueValues | ForEach-Object { "" }) -join "`n" + $filterDropdownsHtml += @" +
+
+ + +
+
+"@ + } + + # Build table headers + $tableHeaders = " Display Name`n User Principal Name" + foreach ($attrName in $AttributeNames) { + $tableHeaders += "`n $attrName" + } + $tableHeaders += "`n User ID" + + # Build table rows + $tableRows = '' + foreach ($user in $UserData) { + $tableRows += " `n" + $tableRows += " $($user.DisplayName)`n" + $tableRows += " $($user.UserPrincipalName)`n" + foreach ($attrName in $AttributeNames) { + $value = $user.$attrName + $displayValue = if ($value -ne '-') { $value } else { 'Not Set' } + $tableRows += " $displayValue`n" + } + $tableRows += " $($user.UserId)`n" + $tableRows += " `n" + } + + # Build body content + $bodyContentHtml = @" +
+ +
+$filterDropdownsHtml +
+
+ +
+
+ User Custom Security Attributes ($AttributeSet) +
+ + Show all +
+
+
+
+ + + +$tableHeaders + + + +$tableRows + +
+
+
+
+ + +"@ + + # Generate final HTML using shared template + $htmlContent = New-RKSolutionsReportTemplate ` + -TenantName $tenantName ` + -ReportTitle 'Security Attributes' ` + -ReportSlug 'custom-security-attributes' ` + -Eyebrow 'CUSTOM SECURITY ATTRIBUTES' ` + -Lede "Users with custom security attribute assignments across the $AttributeSet attribute set." ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml ` + -ReportDate $reportDate ` + -Tags @($AttributeSet, 'Entra ID', 'Security') + + $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 + Write-Host "HTML report saved to: $ExportPath" -ForegroundColor Green + + if (-not $SendEmail) { + if ($IsWindows -or (-not (Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue))) { + Invoke-Item $ExportPath + } else { + & open $ExportPath + } + } + + return $ExportPath +} diff --git a/module/Public/Connect-RKGraph.ps1 b/module/Public/Connect-RKGraph.ps1 index 33bc7b0..27ed3c7 100644 --- a/module/Public/Connect-RKGraph.ps1 +++ b/module/Public/Connect-RKGraph.ps1 @@ -31,7 +31,8 @@ function Connect-RKGraph { 'PrivilegedEligibilitySchedule.Read.AzureADGroup', 'Mail.Send', 'CloudLicensing.Read', - 'CloudPC.Read.All' + 'CloudPC.Read.All', + 'CustomSecAttributeAssignment.Read.All' ), [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ParameterSetName = 'Certificate')] diff --git a/module/Public/Get-CustomSecurityAttributesReport.ps1 b/module/Public/Get-CustomSecurityAttributesReport.ps1 new file mode 100644 index 0000000..2e68935 --- /dev/null +++ b/module/Public/Get-CustomSecurityAttributesReport.ps1 @@ -0,0 +1,106 @@ +<# +.SYNOPSIS + Generates an interactive HTML report of users with custom security attributes in Microsoft Entra ID. + Connect first with Connect-RKGraph; this cmdlet uses the existing connection. +.DESCRIPTION + Connects to Microsoft Graph API and retrieves all users with specified custom security attributes. + Supports dynamic attribute set selection, filtering, and sending reports via email. + The report includes filterable tables with export capabilities (Excel, CSV, PDF, Print). +.PARAMETER AttributeSet + The custom security attribute set to query. Defaults to "CustomerData". +.PARAMETER AttributeNames + Specific attribute names to include. If not specified, all attributes in the set are used. +.PARAMETER Filters + Hashtable of attribute name/value pairs to filter users. Example: @{ CustomerName = "Contoso" } +.PARAMETER SendEmail + Send the report as an email attachment via Microsoft Graph Mail.Send. +.PARAMETER Recipient + Email addresses to send the report to. Required when -SendEmail is used. +.PARAMETER From + Sender email address. If omitted, sends as the currently authenticated user. +.PARAMETER ExportPath + Full path for the HTML report file. Defaults to current directory. +.PARAMETER DebugMode + Enable verbose debug output. +.EXAMPLE + Get-CustomSecurityAttributesReport + Generate report with default CustomerData attribute set. +.EXAMPLE + Get-CustomSecurityAttributesReport -AttributeSet "Engineering" -AttributeNames "Project","CostCenter" + Query specific attribute set with custom attributes. +.EXAMPLE + Get-CustomSecurityAttributesReport -SendEmail -Recipient "admin@contoso.com" + Generate report and email it. +#> +function Get-CustomSecurityAttributesReport { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$AttributeSet = "CustomerData", + + [Parameter(Mandatory = $false)] + [string[]]$AttributeNames, + + [Parameter(Mandatory = $false)] + [hashtable]$Filters, + + [Parameter(Mandatory = $false)] + [switch]$SendEmail, + + [Parameter(Mandatory = $false)] + [string[]]$Recipient, + + [Parameter(Mandatory = $false)] + [string]$From, + + [Parameter(Mandatory = $false)] + [string]$ExportPath, + + [Parameter(Mandatory = $false)] + [switch]$DebugMode + ) + + $ErrorActionPreference = 'Stop' + try { + $ctx = Get-MgContext -ErrorAction SilentlyContinue + if (-not $ctx) { throw 'Not connected to Microsoft Graph. Run Connect-RKGraph first.' } + + # Get tenant information + $tenantInfo = Invoke-MgGraphRequest -Uri 'v1.0/organization' -Method Get -OutputType PSObject + $tenantName = $tenantInfo.value[0].displayName + + # Fetch attribute data + $reportData = Get-CustomSecurityAttributeData -AttributeSet $AttributeSet -AttributeNames $AttributeNames -Filters $Filters -DebugMode:$DebugMode + + if ($reportData.UserData.Count -eq 0) { + Write-Host 'No users found matching the criteria.' -ForegroundColor Yellow + return + } + + Write-Host "Found $($reportData.UserData.Count) user(s) with custom security attributes." -ForegroundColor Green + + # Generate HTML report + $htmlPath = New-CustomSecurityAttributesHTMLReport -TenantName $tenantName -UserData $reportData.UserData -AttributeSet $AttributeSet -AttributeNames $reportData.AttributeNames -ExportPath $ExportPath + + # Send email if requested + if ($SendEmail -and $Recipient) { + $subject = "$tenantName - Custom Security Attributes Report" + $bodyHtml = @" + +

Custom Security Attributes Report

+

Attached is the latest Custom Security Attributes report for $tenantName.

+

Open the attached HTML in a browser for the interactive dashboard.

+

Generated by RKSolutions - please do not reply.

+ +"@ + $emailSent = Send-EmailWithAttachment -Recipient $Recipient -AttachmentPath $htmlPath -From $From -Subject $subject -BodyHtml $bodyHtml + if ($emailSent) { Write-Host 'INFO: Email sent successfully.' -ForegroundColor Green } else { Write-Host 'ERROR: Failed to send email.' -ForegroundColor Red } + } + + if ($SendEmail -and (Test-Path -Path $htmlPath)) { + Remove-Item -Path $htmlPath -Force + Write-Host 'INFO: Temporary report file deleted.' -ForegroundColor Green + } + } + catch { Write-Error "Error: $_"; throw $_ } +} diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 4e3ca30..dbcf24a 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -39,7 +39,7 @@ if (Test-Path $privatePath) { if (Test-Path $fp) { . $fp } } # Report-specific private scripts (order matters if they depend on each other) - $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1') + $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') foreach ($name in $domainOrder) { $fp = Join-Path $privatePath $name if (Test-Path $fp) { . $fp } @@ -59,5 +59,6 @@ Export-ModuleMember -Function @( 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', 'Get-EntraAdminRolesReport', - 'Get-M365LicenseAssignmentReport' + 'Get-M365LicenseAssignmentReport', + 'Get-CustomSecurityAttributesReport' ) From 86da55e4110ea46371a30bbe856bee92ad45fc17 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 21:49:25 +0200 Subject: [PATCH 03/11] refactor: migrate Intune Anomalies report to shared brand template Replaces ~920 lines of inline HTML/CSS/JS with a call to New-RKSolutionsReportTemplate. All report-specific content (8 stat tiles, 8 tabbed DataTables, filters) preserved as template parameters. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/IntuneAnomalies.ps1 | 2963 +++++++++------------------- 1 file changed, 946 insertions(+), 2017 deletions(-) diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 24e6bae..7749a35 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -4,7 +4,7 @@ function New-IntuneAnomaliesHTMLReport { param( [Parameter(Mandatory = $true)] [string]$TenantName, - + [Parameter(Mandatory = $false)] [array]$Report_ApplicationFailureReport, [Parameter(Mandatory = $false)] @@ -39,1935 +39,10 @@ function New-IntuneAnomaliesHTMLReport { $Report_NoncompliantDevices_Count = ($Report_NoncompliantDevices | Select-Object -Property DeviceName -Unique | Measure-Object).Count $Report_OperationSystemEdtionOverview_Count = $Report_OperationSystemEdtionOverview | Measure-Object | Select-Object -ExpandProperty Count $Report_DisabledPrimaryUsers_Count = $Report_DisabledPrimaryUsers | Measure-Object | Select-Object -ExpandProperty Count - + # Get the current date and time for the report header $CurrentDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - # Create HTML Template with DataTables - $htmlTemplate = @' - - - - - -$TenantName Intune Anomalies Report - - - - - - - - - - - - - - - - - - - -
-
- -
- -
- -
-
- -
-
-
- -

$TenantName Intune Anomalies Report

-
-
- Report generated on: $ReportDate -
-
- -
-
-
- -

Application Failures

-
$applicationFailures
-
-
-
-
- -

Multiple users on non-shared devices

-
$multipleUsers
-
-
-
-
- -

Not Encrypted Devices

-
$notEncrypted
-
-
-
-
- -

No Autopilot Hash

-
$noAutopilot
-
-
-
-
- -

Inactive Devices

-
$inactiveDevices
-
-
-
-
- -

Noncompliant Devices

-
$noncompliantDevices
-
-
-
-
- -

OS Edition Overview

-
$osEditionOverview
-
-
-
-
- -

Disabled Primary Users

-
$disabledPrimaryUsers
-
-
-
- -
-
Application Failures
-
Multiple Users
-
Not Encrypted
-
No Autopilot Hash
-
Inactive Devices
-
Noncompliant
-
OS Edition Overview
-
Disabled Primary Users
- -
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Application Failures -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - {{APPLICATION_FAILURES_DATA}} - -
CustomerApplicationPlatformVersionFailed Device CountFailed Device Percentage
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Devices with Multiple Users -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - {{MULTIPLE_USERS_DATA}} - -
CustomerDevice NamePrimary UserEnrollment ProfileUser CountLogged On User IDs
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Not Encrypted Devices -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - {{NOT_ENCRYPTED_DATA}} - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Non-company owned devices -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - {{NO_AUTOPILOT_DATA}} - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Inactive Devices (90+ days) -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - {{INACTIVE_DEVICES_DATA}} - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModelLast Contact
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Noncompliant Devices -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - {{NONCOMPLIANT_DEVICES_DATA}} - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModelCompliance StatusNoncompliant Based OnNoncompliant Alert
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Operating System Edition Overview -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - {{OS_EDITION_OVERVIEW_DATA}} - -
CustomerDevice NamePrimary UserOperating System EditionOS Friendly Name
-
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-
-
- Devices with Disabled Primary Users -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - {{DISABLED_PRIMARY_USERS_DATA}} - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
-
-
-
-
- -
-
-

Generated by Roy Klooster - RK Solutions

-
- - - - -'@ - # Generate table rows for all application failures $applicationFailureRows = "" foreach ($item in $Report_ApplicationFailureReport) { @@ -1992,7 +67,7 @@ table.dataTable tfoot td { $($item.DeviceName) $($item.PrimaryUser) $($item.EnrollmentProfile) - $($item.usersLoggedOnCount) + $($item.usersLoggedOnCount) $($item.usersLoggedOnIds) "@ @@ -2047,8 +122,8 @@ table.dataTable tfoot td { # Generate table rows for noncompliant devices $noncompliantDevicesRows = "" foreach ($item in $Report_NoncompliantDevices) { - $statusBadge = 'Noncompliant' - + $statusBadge = 'Noncompliant' + $noncompliantDevicesRows += @" $($item.Customer) @@ -2085,7 +160,7 @@ table.dataTable tfoot td { $($item.Customer) $($item.DeviceName) - $($item.PrimaryUser) + $($item.PrimaryUser) $($item.Serialnumber) $($item.DeviceManufacturer) $($item.DeviceModel) @@ -2093,26 +168,881 @@ table.dataTable tfoot td { "@ } - # Replace placeholders in template with actual values - $htmlContent = $htmlTemplate - $htmlContent = $htmlContent.Replace('$TenantName', $TenantName) - $htmlContent = $htmlContent.Replace('$ReportDate', $currentDate) - $htmlContent = $htmlContent.Replace('{{APPLICATION_FAILURES_DATA}}', $applicationFailureRows) - $htmlContent = $htmlContent.Replace('{{MULTIPLE_USERS_DATA}}', $multipleUsersRows) - $htmlContent = $htmlContent.Replace('{{NOT_ENCRYPTED_DATA}}', $notEncryptedRows) - $htmlContent = $htmlContent.Replace('{{NO_AUTOPILOT_DATA}}', $noAutopilotHashRows) - $htmlContent = $htmlContent.Replace('{{INACTIVE_DEVICES_DATA}}', $inactiveDevicesRows) - $htmlContent = $htmlContent.Replace('{{NONCOMPLIANT_DEVICES_DATA}}', $noncompliantDevicesRows) - $htmlContent = $htmlContent.Replace('{{OS_EDITION_OVERVIEW_DATA}}', $osEditionOverviewRows) - $htmlContent = $htmlContent.Replace('{{DISABLED_PRIMARY_USERS_DATA}}', $disabledPrimaryUsersRows) - $htmlContent = $htmlContent.Replace('$applicationFailures', $Report_ApplicationFailureReport_Count) - $htmlContent = $htmlContent.Replace('$multipleUsers', $Report_DevicesWithMultipleUsers_Count) - $htmlContent = $htmlContent.Replace('$notEncrypted', $Report_NotEncryptedDevices_Count) - $htmlContent = $htmlContent.Replace('$noAutopilot', $Report_DevicesWithoutAutopilotHash_Count) - $htmlContent = $htmlContent.Replace('$inactiveDevices', $Report_InactiveDevices_Count) - $htmlContent = $htmlContent.Replace('$noncompliantDevices', $Report_NoncompliantDevices_Count) - $htmlContent = $htmlContent.Replace('$osEditionOverview', $Report_OperationSystemEdtionOverview_Count) - $htmlContent = $htmlContent.Replace('$disabledPrimaryUsers', $Report_DisabledPrimaryUsers_Count) + # Build stat tiles HTML (8 cards, cycling through t-rust, t-olive, t-steel, t-rose) + $statsCardsHtml = @" +
+
APPLICATION FAILURES
+
$Report_ApplicationFailureReport_Count
+
Failed app installations
+
+
+
MULTIPLE USERS
+
$Report_DevicesWithMultipleUsers_Count
+
Non-shared devices
+
+
+
NOT ENCRYPTED
+
$Report_NotEncryptedDevices_Count
+
Unencrypted devices
+
+
+
NO AUTOPILOT HASH
+
$Report_DevicesWithoutAutopilotHash_Count
+
Missing hardware hash
+
+
+
INACTIVE DEVICES
+
$Report_InactiveDevices_Count
+
90+ days inactive
+
+
+
NONCOMPLIANT
+
$Report_NoncompliantDevices_Count
+
Unique noncompliant devices
+
+
+
OS EDITIONS
+
$Report_OperationSystemEdtionOverview_Count
+
OS edition entries
+
+
+
DISABLED USERS
+
$Report_DisabledPrimaryUsers_Count
+
Disabled primary users
+
+"@ + + # Build body content HTML (tabs + panels + filter containers + tables + script) + $bodyContentHtml = @" + +
+ + + + + + + + +
+ + +
+
+ Filters: + + + + + + +
+
+
+ Application Failures +
+ + Show all +
+
+
+ + + + + + + + + + + + + $applicationFailureRows + +
CustomerApplicationPlatformVersionFailed Device CountFailed Device Percentage
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Devices with Multiple Users +
+ + Show all +
+
+
+ + + + + + + + + + + + + $multipleUsersRows + +
CustomerDevice NamePrimary UserEnrollment ProfileUser CountLogged On User IDs
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Not Encrypted Devices +
+ + Show all +
+
+
+ + + + + + + + + + + + + $notEncryptedRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Non-company owned devices +
+ + Show all +
+
+
+ + + + + + + + + + + + + $noAutopilotHashRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
+
+
+
+ + +
+
+ Filters: + + + + + + + +
+
+
+ Inactive Devices (90+ days) +
+ + Show all +
+
+
+ + + + + + + + + + + + + + $inactiveDevicesRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModelLast Contact
+
+
+
+ + +
+
+ Filters: + + + + + + + +
+
+
+ Noncompliant Devices +
+ + Show all +
+
+
+ + + + + + + + + + + + + + + + $noncompliantDevicesRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModelCompliance StatusNoncompliant Based OnNoncompliant Alert
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Operating System Edition Overview +
+ + Show all +
+
+
+ + + + + + + + + + + + $osEditionOverviewRows + +
CustomerDevice NamePrimary UserOperating System EditionOS Friendly Name
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Devices with Disabled Primary Users +
+ + Show all +
+
+
+ + + + + + + + + + + + + $disabledPrimaryUsersRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
+
+
+
+ + +"@ + + # Report-specific CSS (minimal -- filter bar inline styles only) + $customCss = @" + .rk-filter-bar .form-select { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 6px; + } +"@ + + # Generate the full HTML report using the shared template + $htmlContent = New-RKSolutionsReportTemplate ` + -TenantName $TenantName ` + -ReportTitle 'Anomalies' ` + -ReportSlug 'intune-anomalies' ` + -Eyebrow 'INTUNE ANOMALIES' ` + -Lede 'Device compliance overview with flagged anomalies across encryption, activity, and application health.' ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml ` + -CustomCss $customCss ` + -ReportDate $CurrentDate ` + -Tags @('Intune', 'Compliance', 'Security') # Export to HTML file $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 @@ -2136,7 +1066,7 @@ function Get-AllDeviceData { $Customer ) - @{ + @{ "0" = "unknown" "4" = "Windows 10/11 Enterprise" "27" = "Windows 10/11 Enterprise N" @@ -2169,38 +1099,38 @@ function Get-AllDeviceData { default { return "Other" } } } - - function Convert-Size { - [cmdletbinding()] - param( - [validateset("Bytes", "KB", "MB", "GB", "TB")] - [string]$From, - [validateset("Bytes", "KB", "MB", "GB", "TB")] - [string]$To, - [Parameter(Mandatory = $true)] - [double]$Value, - [int]$Precision = 4 - ) - switch ($From) { - "Bytes" { $value = $Value } - "KB" { $value = $Value * 1024 } - "MB" { $value = $Value * 1024 * 1024 } - "GB" { $value = $Value * 1024 * 1024 * 1024 } - "TB" { $value = $Value * 1024 * 1024 * 1024 * 1024 } - } - - switch ($To) { - "Bytes" { return $value } - "KB" { $Value = $Value / 1KB } - "MB" { $Value = $Value / 1MB } - "GB" { $Value = $Value / 1GB } - "TB" { $Value = $Value / 1TB } - - } - - $Calc = [Math]::Round($value, $Precision, [MidPointRounding]::AwayFromZero) - return "$calc $to" - + + function Convert-Size { + [cmdletbinding()] + param( + [validateset("Bytes", "KB", "MB", "GB", "TB")] + [string]$From, + [validateset("Bytes", "KB", "MB", "GB", "TB")] + [string]$To, + [Parameter(Mandatory = $true)] + [double]$Value, + [int]$Precision = 4 + ) + switch ($From) { + "Bytes" { $value = $Value } + "KB" { $value = $Value * 1024 } + "MB" { $value = $Value * 1024 * 1024 } + "GB" { $value = $Value * 1024 * 1024 * 1024 } + "TB" { $value = $Value * 1024 * 1024 * 1024 * 1024 } + } + + switch ($To) { + "Bytes" { return $value } + "KB" { $Value = $Value / 1KB } + "MB" { $Value = $Value / 1MB } + "GB" { $Value = $Value / 1GB } + "TB" { $Value = $Value / 1TB } + + } + + $Calc = [Math]::Round($value, $Precision, [MidPointRounding]::AwayFromZero) + return "$calc $to" + } # Optimized Properties List - Only essential properties for better performance @@ -2235,7 +1165,7 @@ function Get-AllDeviceData { $AllDeviceData = Invoke-graphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" #filter out managed by MDE $AllDeviceData = $AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" } - + # Get all AutoPilot registered devices under "Enrollment" Write-Host "Fetching Autopilot devices..." -ForegroundColor Yellow $AutopilotDevices = (Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities") @@ -2255,23 +1185,23 @@ function Get-AllDeviceData { # Loop through all devices for device data $results = [System.Collections.Generic.List[PSObject]]::new() $totalDevices = $AllDeviceData.Count - + Write-Host "Processing $totalDevices devices..." -ForegroundColor Yellow for ($i = 0; $i -lt $AllDeviceData.Count; $i++) { $DeviceData = $AllDeviceData[$i] $currentIndex = $i + 1 - + # Calculate progress percentage $progressPercent = [math]::Round(($currentIndex / $totalDevices) * 100, 1) - + # Show progress bar instead of Write-Host Write-Progress -Activity "Processing Intune Devices" -Status "Processing device: $($DeviceData.DeviceName)" -CurrentOperation "$currentIndex of $totalDevices devices processed" -PercentComplete $progressPercent try { # Use bulk-fetched data directly (no per-device re-fetch needed - same $select) $DeviceProperties = $DeviceData - + # Process Autopilot information via pre-built hashtable $AutopilotInfo = $AutopilotLookup[$DeviceData.SerialNumber] $HashUploaded = $AutopilotLookup.ContainsKey($DeviceData.SerialNumber) @@ -2286,12 +1216,12 @@ function Get-AllDeviceData { if ($DeviceData.complianceState -eq "noncompliant") { try { $ComplianceRules = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($DeviceData.id)/deviceCompliancePolicyStates" -ErrorAction SilentlyContinue).value | Where-Object { $_.State -eq "nonCompliant" -or $_.State -eq "Error" } - + if ($ComplianceRules -and $ComplianceRules.count -le 10) { foreach ($ComplianceRule in $ComplianceRules) { try { $ruleDetails = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($DeviceData.id)/deviceCompliancePolicyStates/$($ComplianceRule.id)/settingStates" -ErrorAction SilentlyContinue).value | Where-Object { $_.state -match 'nonCompliant' } - + if ($ruleDetails) { # Add individual rule settings to the collection foreach ($ruleDetail in $ruleDetails) { @@ -2304,7 +1234,7 @@ function Get-AllDeviceData { Write-Verbose "Failed to get compliance rule details for $($DeviceData.DeviceName): $_" } } - + # **FIX**: Get unique values only to eliminate duplicates $uniqueRules = $allRules | Select-Object -Unique } @@ -2326,16 +1256,16 @@ function Get-AllDeviceData { } # Handle storage calculations with null checking - $TotalStorageFormatted = if ($DeviceProperties.TotalStorageSpaceInBytes -and $DeviceProperties.TotalStorageSpaceInBytes -gt 0) { - Convert-Size -From bytes -To GB -Value $DeviceProperties.TotalStorageSpaceInBytes -Precision 2 - } else { - "N/A" + $TotalStorageFormatted = if ($DeviceProperties.TotalStorageSpaceInBytes -and $DeviceProperties.TotalStorageSpaceInBytes -gt 0) { + Convert-Size -From bytes -To GB -Value $DeviceProperties.TotalStorageSpaceInBytes -Precision 2 + } else { + "N/A" } - - $FreeStorageFormatted = if ($DeviceProperties.FreeStorageSpaceInBytes -and $DeviceProperties.FreeStorageSpaceInBytes -gt 0) { - Convert-Size -From bytes -To GB -Value $DeviceProperties.FreeStorageSpaceInBytes -Precision 2 - } else { - "N/A" + + $FreeStorageFormatted = if ($DeviceProperties.FreeStorageSpaceInBytes -and $DeviceProperties.FreeStorageSpaceInBytes -gt 0) { + Convert-Size -From bytes -To GB -Value $DeviceProperties.FreeStorageSpaceInBytes -Precision 2 + } else { + "N/A" } # Access hardware information with null checking @@ -2363,7 +1293,7 @@ function Get-AllDeviceData { AutopilotAssignedUser = if ($AutopilotInfo.userprincipalname) { $AutopilotInfo.userprincipalname } else { $null } EnrollmentProfile = $DeviceProperties.EnrollmentProfileName Encrypted = $DeviceProperties.IsEncrypted - DeviceEnrollmentType = $DeviceProperties.DeviceEnrollmentType + DeviceEnrollmentType = $DeviceProperties.DeviceEnrollmentType usersLoggedOnIds = if ($ExistingLoggedInUsers) { $ExistingLoggedInUsers -join ', ' } else { "" } usersLoggedOnCount = if ($LoggedInUsers) { $LoggedInUsers.Count } else { 0 } Operatingsystem = $DeviceProperties.OperatingSystem @@ -2389,14 +1319,14 @@ function Get-AllDeviceData { Write-Host "Device processing completed!" -ForegroundColor Green Write-Host "Processed $($results.Count) devices out of $totalDevices total devices" -ForegroundColor Green - + # Debug output for hardware information availability $devicesWithoutHardwareInfo = $results | Where-Object { $_.TPMversion -eq "Unknown" } - + if ($devicesWithoutHardwareInfo.Count -gt 0) { Write-Host "Some devices are missing hardware information:" -ForegroundColor Yellow foreach ($device in $devicesWithoutHardwareInfo) { - Write-Host " - $($device.DeviceName) (Serial: $($device.Serialnumber))" -ForegroundColor Gray + Write-Host " - $($device.DeviceName) (Serial: $($device.Serialnumber))" -ForegroundColor Gray } } return $results @@ -2408,7 +1338,7 @@ function Get-ApplicationFailures { # Cross-platform temporary file path # Detect OS and set appropriate temp path $detectedWindows = $false - + # Check if automatic variables exist (PowerShell Core 6.0+) if (Get-Variable -Name "IsWindows" -ErrorAction SilentlyContinue) { $detectedWindows = $IsWindows @@ -2435,7 +1365,7 @@ function Get-ApplicationFailures { } } } - + # Set temporary directory and file path based on OS if ($detectedWindows) { $tempDir = "C:\temp" @@ -2443,17 +1373,17 @@ function Get-ApplicationFailures { # macOS and Linux $tempDir = "/tmp" } - + # Use Join-Path for cross-platform compatibility $Data = Join-Path $tempDir "Data.log" - + # Ensure temp directory exists if (-not (Test-Path -Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null } $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value - + $params = @{ Select = @( "DisplayName" @@ -2497,18 +1427,18 @@ function Get-ApplicationFailures { if ($AppAssignment) { $AssignmentStatus = $true } - + # Use Platform_loc instead of Platform for better readability $PlatformName = if ($LineObject.Platform_loc) { $LineObject.Platform_loc } else { $LineObject.Platform } - + $ReturnObject.Add([PSCustomObject][ordered]@{ - Customer = $tenantname - Application = ($apps | Where-Object { $_.Id -eq $LineObject.ApplicationId }).displayName + Customer = $tenantname + Application = ($apps | Where-Object { $_.Id -eq $LineObject.ApplicationId }).displayName Platform = $PlatformName Version = $LineObject.AppVersion AssignmentStatus = $AssignmentStatus FailedUserCount = $LineObject.FailedUserCount - FailedDeviceCount = $LineObject.FailedDeviceCount + FailedDeviceCount = $LineObject.FailedDeviceCount FailedDevicePercentage = [double]($LineObject.FailedDevicePercentage / 100).toString('0.00') }) | Out-Null } @@ -2523,6 +1453,5 @@ function Get-ApplicationFailures { } function Get-AutopilotProfilesInformation { -(Invoke-GraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/" -OutputType PSObject).value +(Invoke-GraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/" -OutputType PSObject).value } - From 73274e6fc1c1213f85d3ae954cc95e38479567d5 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 21:57:59 +0200 Subject: [PATCH 04/11] refactor: migrate Entra Admin Roles report to shared brand template Replaces ~2050 lines of inline HTML/CSS/JS with a call to New-RKSolutionsReportTemplate. Preserves shimmer animation and group-jump highlight as report-specific CustomCss. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/EntraAdminRoles.ps1 | 3181 ++++++++-------------------- 1 file changed, 860 insertions(+), 2321 deletions(-) diff --git a/module/Private/EntraAdminRoles.ps1 b/module/Private/EntraAdminRoles.ps1 index d33b284..d1e533b 100644 --- a/module/Private/EntraAdminRoles.ps1 +++ b/module/Private/EntraAdminRoles.ps1 @@ -4,16 +4,16 @@ function New-AdminRoleHTMLReport { param( [Parameter(Mandatory = $true)] [string]$TenantName, - + [Parameter(Mandatory = $true)] [array]$Report, - + [Parameter(Mandatory = $false)] [array]$GroupAssignmentReport, - + [Parameter(Mandatory = $false)] [array]$ServicePrincipalReport, - + [Parameter(Mandatory = $false)] [array]$UserAssignmentReport, @@ -22,7 +22,7 @@ function New-AdminRoleHTMLReport { [Parameter(Mandatory = $false)] [array]$PIMAuditLogsReport, - + [Parameter(Mandatory = $false)] [string]$ExportPath ) @@ -42,2080 +42,24 @@ function New-AdminRoleHTMLReport { # Get the current date and time for the report header $CurrentDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - # Create HTML Template with DataTables - $htmlTemplate = @' - - - - - - $TenantName Admin Roles Report - - - - - - - - - - - - - - - - - - - - -
-
- -
- -
- -
-
- -
-
-
- -

$TenantName Admin Roles Report

-
-
- Report generated on: $ReportDate -
-
- -
-
-
- -

Permanent Assignments

-
$permanentRoles
-
-
-
-
- -

Eligible Assignments

-
$eligibleRoles
-
-
-
-
- -

Group Assignments

-
$groupAssignedRoles
-
-
-
-
- -

Service Principal Assignments

-
$servicePrincipalRoles
-
-
-
- -
-
- -
-
Filter Options
- -
-
-
- - -
-
-
-
- - -
-
-
- -
- - -
- -
- - -
- -
-
- - -
-
- -
-
-
- -
-
-
-
- All Role Assignments -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - {{ALL_ROLES_DATA}} - -
PrincipalDisplay NamePrincipal TypeAccount StatusAssigned RoleRole ScopeAssignment TypeStart DateEnd Date
-
-
-
-
- -
-
-
-
- User Role Assignments -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - {{USER_ROLES_DATA}} - -
PrincipalDisplay NamePrincipal TypeAccount StatusAssigned RoleRole ScopeAssignment TypeStart DateEnd Date
-
-
-
-
- -
-
-
-
- Group Role Assignments -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - - - {{GROUP_ROLES_DATA}} - -
PrincipalDisplay NamePrincipal TypeAccount StatusAssigned RoleRole ScopeAssignment TypeStart DateEnd DateMembersActivated Members
-
-
-
-
- -
-
-
-
- Service Principal Role Assignments -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - {{SP_ROLES_DATA}} - -
PrincipalDisplay NamePrincipal TypeAccount StatusAssigned RoleRole ScopeAssignment TypeStart DateEnd Date
-
-
-
-
- -
-
-
PIM Audit Logs Filters
- -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
-
- -
- - to - -
-
-
-
- -
-
- - -
-
- -
-
-
- -
-
-
- PIM Audit Logs -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - - - - {{PIM_AUDIT_LOGS_DATA}} - -
Date/TimeInitiated ByOperation TypeInitiator TypeRoleTargetOperationResultRole PropertiesJustification
-
-
-
-
-
-

Generated by Roy Klooster - RK Solutions

-
- - - - -'@ - # Generate table rows for all role assignments $allRolesRows = "" - foreach ($item in $Report) { + foreach ($item in $Report) { $assignmentTypeBadge = switch ($item.AssignmentType) { - "Permanent" { "Permanent" } - "Eligible" { "Eligible" } - "Eligible (Active)" { + "Permanent" { "Permanent" } + "Eligible" { "Eligible" } + "Eligible (Active)" { if ($item.PrincipalType -eq "group") { # Create a safe ID from the principal name and role $safeId = ($item.Principal + "-" + $item.'Assigned Role').Replace(" ", "-").Replace("@", "-").Replace(".", "-") - "Eligible (Active) " + "Eligible (Active) " } else { - "Eligible (Active)" + "Eligible (Active)" } } - default { "Unknown" } + default { "Unknown" } } - + $allRolesRows += @" $($item.Principal) @@ -2135,12 +79,12 @@ function New-AdminRoleHTMLReport { $userRolesRows = "" foreach ($item in $UserAssignmentReport) { $assignmentTypeBadge = switch ($item.AssignmentType) { - "Permanent" { "Permanent" } - "Eligible" { "Eligible" } - "Eligible (Active)" { "Eligible (Active)" } - default { "Unknown" } + "Permanent" { "Permanent" } + "Eligible" { "Eligible" } + "Eligible (Active)" { "Eligible (Active)" } + default { "Unknown" } } - + $userRolesRows += @" $($item.Principal) @@ -2160,18 +104,18 @@ function New-AdminRoleHTMLReport { $groupRolesRows = "" foreach ($item in $GroupAssignmentReport) { $assignmentTypeBadge = switch ($item.AssignmentType) { - "Permanent" { "Permanent" } - "Eligible" { "Eligible" } - "Eligible (Active)" { "Eligible (Active)" } - default { "Unknown" } + "Permanent" { "Permanent" } + "Eligible" { "Eligible" } + "Eligible (Active)" { "Eligible (Active)" } + default { "Unknown" } } - + # Get group members from the overview report $groupMembers = ($GroupMembershipOverviewReport | Where-Object { $_.Principal -eq $item.Principal }).Members if (-not $groupMembers) { $groupMembers = "None" } - + # Format activated members information (simplified - UserPrincipalName only) $activatedMembersText = "None" if ($item.ActivatedMembers -and @($item.ActivatedMembers).Count -gt 0) { @@ -2181,10 +125,10 @@ function New-AdminRoleHTMLReport { } $activatedMembersText = $activatedList -join "
" } - + # Create a safe ID from the principal name and role for targeting $safeId = ($item.Principal + "-" + $item.'Assigned Role').Replace(" ", "-").Replace("@", "-").Replace(".", "-") - + $groupRolesRows += @" $($item.Principal) @@ -2206,12 +150,12 @@ function New-AdminRoleHTMLReport { $spRolesRows = "" foreach ($item in $ServicePrincipalReport) { $assignmentTypeBadge = switch ($item.AssignmentType) { - "Permanent" { "Permanent" } - "Eligible" { "Eligible" } - "Eligible (Active)" { "Eligible (Active)" } - default { "Unknown" } + "Permanent" { "Permanent" } + "Eligible" { "Eligible" } + "Eligible (Active)" { "Eligible (Active)" } + default { "Unknown" } } - + $spRolesRows += @" $($item.Principal) @@ -2232,11 +176,11 @@ function New-AdminRoleHTMLReport { if ($PIMAuditLogsReport -and $PIMAuditLogsReport.Count -gt 0) { foreach ($log in $PIMAuditLogsReport) { $resultBadge = switch ($log.Result) { - "Success" { "Success" } - "Failure" { "Failure" } - default { "Unknown" } + "Success" { "Success" } + "Failure" { "Failure" } + default { "Unknown" } } - + $pimAuditLogsRows += @" $($log.DateTime) @@ -2254,118 +198,713 @@ function New-AdminRoleHTMLReport { } } - # Replace placeholders in template with actual values - $htmlContent = $htmlTemplate - $htmlContent = $htmlContent.Replace('$TenantName', $TenantName) - $htmlContent = $htmlContent.Replace('$ReportDate', $currentDate) - $htmlContent = $htmlContent.Replace('$permanentRoles', $permanentRoles) - $htmlContent = $htmlContent.Replace('$eligibleRoles', $eligibleRoles) - $htmlContent = $htmlContent.Replace('$groupAssignedRoles', $groupAssignedRoles) - $htmlContent = $htmlContent.Replace('$servicePrincipalRoles', $servicePrincipalRoles) - $htmlContent = $htmlContent.Replace('{{PIM_AUDIT_LOGS_DATA}}', $pimAuditLogsRows) - - # Add report tabs based on available data - $reportTabs = @" -
Assignments
+ # Build stat tiles HTML + $statsCardsHtml = @" +
+
PERMANENT
+
$permanentRoles
+
Permanent assignments
+
+
+
ELIGIBLE
+
$eligibleRoles
+
Eligible assignments
+
+
+
GROUP
+
$groupAssignedRoles
+
Group assignments
+
+
+
SERVICE PRINCIPAL
+
$servicePrincipalRoles
+
Service principal assignments
+
+"@ + + # Build dynamic tab buttons + $tabButtons = @" + "@ if ($UserAssignmentReport.Count -gt 0) { - $reportTabs += @" - + $tabButtons += @" + "@ } - + if ($GroupAssignmentReport.Count -gt 0) { - $reportTabs += @" - + $tabButtons += @" + "@ } - + if ($ServicePrincipalReport.Count -gt 0) { - $reportTabs += @" - + $tabButtons += @" + "@ } - if ($PIMAuditLogsReport -and $PIMAuditLogsReport.Count -gt 0) { - $reportTabs += @" -
PIM Audit Logs
+ $tabButtons += @" + "@ } - - $htmlContent = $htmlContent.Replace("
", "
`n$reportTabs") - - # Replace table data placeholders - $htmlContent = $htmlContent.Replace('{{ALL_ROLES_DATA}}', $allRolesRows) - $htmlContent = $htmlContent.Replace('{{USER_ROLES_DATA}}', $userRolesRows) - $htmlContent = $htmlContent.Replace('{{GROUP_ROLES_DATA}}', $groupRolesRows) - $htmlContent = $htmlContent.Replace('{{SP_ROLES_DATA}}', $spRolesRows) - - # Add additional CSS for dark mode pagination - $darkModePaginationCss = @' - -'@ + } + + /* Filter bar form controls */ + .rk-filter-bar .form-select, + .rk-filter-bar .form-control { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 6px; + } +"@ - # Insert the dark mode pagination CSS before the tag - $htmlContent = $htmlContent.Replace("", "$darkModePaginationCss`n") + # Generate the full HTML report using the shared template + $htmlContent = New-RKSolutionsReportTemplate ` + -TenantName $TenantName ` + -ReportTitle 'Admin Roles' ` + -ReportSlug 'entra-admin-roles' ` + -Eyebrow 'ENTRA ADMIN ROLES' ` + -Lede 'Privileged role assignments including permanent, eligible, group-based, and service principal assignments.' ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml ` + -CustomCss $customCss ` + -ReportDate $CurrentDate ` + -Tags @('Entra ID', 'PIM', 'Security') # Export to HTML file $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 @@ -2387,7 +926,7 @@ function Get-SecurityGroups { param ( [switch]$Verbose ) - + $securityGroups = Invoke-GraphRequestWithPaging -Uri "beta/groups?`$filter=isassignabletorole eq true" -Method Get if ($Verbose) { Write-Verbose "Found $($securityGroups.Count) security groups that are assignable to roles" @@ -2403,10 +942,10 @@ function Get-SecurityGroups { } Else { Write-Host "INFO: Collecting members for security group: $($group.displayName)" } - + try { $members = Invoke-GraphRequestWithPaging -Uri "beta/groups/$($group.id)/transitiveMembers?`$select=id,displayName,userPrincipalName" -Method Get - + # Create member list with useful information $memberList = @() foreach ($member in $members) { @@ -2432,7 +971,7 @@ function Get-SecurityGroups { } } } - + # Store the members in the hashtable $securityGroupMembers[$group.id] = @{ GroupDisplayName = $group.displayName @@ -2440,13 +979,13 @@ function Get-SecurityGroups { Members = $memberList MemberCount = $memberList.Count } - + } catch { Write-Error "ERROR: Collecting members for group $($group.displayName): $_" continue } } - + # Return the security group members return $securityGroupMembers } @@ -2454,7 +993,7 @@ function Get-SecurityGroups { Function Get-PIMAuditLogs { # Get PIM audit logs $PIMAudits = Invoke-GraphRequestWithPaging "beta/auditLogs/directoryAudits?`$filter=loggedByService eq 'PIM'" - + $results = @() foreach ($PIMaudit in $PIMAudits) { # Extract user who initiated the action @@ -2478,7 +1017,7 @@ Function Get-PIMAuditLogs { $initiatedByUser = "Unknown" } } - + # Determine initiator type $initiatorType = "Unknown" if ($PIMaudit.InitiatedBy.user) { @@ -2488,12 +1027,12 @@ Function Get-PIMAuditLogs { } elseif ($PIMaudit.InitiatedBy.servicePrincipal) { $initiatorType = "Service Principal" } - + # Get role information $roleResource = $PIMaudit.TargetResources | Where-Object { $_.Type -eq "Role" } $roleName = $roleResource.DisplayName $roleId = $roleResource.id - + # Extract modified properties information $roleProperties = @() if ($roleResource.modifiedProperties) { @@ -2501,7 +1040,7 @@ Function Get-PIMAuditLogs { # Clean up the values $oldValue = $prop.oldValue -replace "^'|'$", "" $newValue = $prop.newValue -replace "^'|'$", "" - + # Make property name more readable $propName = $prop.displayName # Handle common PIM property names @@ -2513,13 +1052,13 @@ Function Get-PIMAuditLogs { "*MemberType*" { $propName = "Member Type" } "*AssignmentState*" { $propName = "Assignment" } } - + # Format datetime values as before... - + # For empty values if ([string]::IsNullOrWhiteSpace($oldValue)) { $oldValue = "(none)" } if ([string]::IsNullOrWhiteSpace($newValue)) { $newValue = "(none)" } - + # Create property change format - using HTML entity for arrow instead of Unicode if ($oldValue -eq "(none)" -and $newValue -ne "(none)") { $roleProperties += "$($propName): $newValue" @@ -2534,47 +1073,47 @@ Function Get-PIMAuditLogs { } } $rolePropertiesText = $roleProperties -join " | " - + # Get request information $requestResource = $PIMaudit.TargetResources | Where-Object { $_.type -eq "Request" } $requestId = $requestResource.id - + # Extract target user details with enhanced group context $userDetails = "N/A" $targetUserId = $null - + # Check target resources for user $userResource = $PIMaudit.TargetResources | Where-Object { $_.type -eq "User" } if ($userResource -and $userResource.userPrincipalName) { $userDetails = $userResource.userPrincipalName $targetUserId = $userResource.id - } - + } + # Check if this is a group-based activation $isGroupBasedActivation = $false $groupInfo = $null - + # Look for group information in additional details $groupDetail = $PIMaudit.AdditionalDetails | Where-Object { $_.key -eq "GroupId" -or $_.key -eq "Group" -or $_.key -eq "MemberType" } if ($groupDetail -and $groupDetail.value -eq "Group") { $isGroupBasedActivation = $true } - + # Check if the operation indicates group-based activation if ($PIMaudit.ActivityDisplayName -like "*group*" -or $rolePropertiesText -like "*Group*") { $isGroupBasedActivation = $true - } - + } + # Get directory information $directoryResource = $PIMaudit.TargetResources | Where-Object { $_.type -eq "Directory" } $directoryName = $directoryResource.displayName - + # Get reason for the action $reason = $PIMaudit.ResultReason if ([string]::IsNullOrWhiteSpace($reason)) { $reason = "N/A" } - + # Extract start time and duration for better activation tracking $startTime = "N/A" $duration = "N/A" @@ -2587,12 +1126,12 @@ Function Get-PIMAuditLogs { $startTime = $startTimeDetail.value } } - + $durationDetail = $PIMaudit.AdditionalDetails | Where-Object { $_.key -eq "Duration" } if ($durationDetail) { $duration = $durationDetail.value } - + # Create custom object with enhanced information for group activation tracking $results += [PSCustomObject]@{ "DateTime" = $PIMaudit.ActivityDateTime @@ -2615,7 +1154,7 @@ Function Get-PIMAuditLogs { "Duration" = $duration } } - + # Sort the results by DateTime in descending order $results = $results | Sort-Object -Property DateTime -Descending # Return the results @@ -2633,14 +1172,14 @@ function Get-GroupActivationDetails { [Parameter(Mandatory = $true)] [string]$RoleName ) - + $enrichedActivations = @() - + # Return empty array if no activated members (PowerShell 5 compatible) if (-not $ActivatedMembers -or @($ActivatedMembers).Count -eq 0) { return $enrichedActivations } - + foreach ($member in $ActivatedMembers) { # Find corresponding PIM audit logs for this member and role $memberAuditLogs = $PIMAuditLogs | Where-Object { @@ -2650,10 +1189,10 @@ function Get-GroupActivationDetails { $_.Result -eq "Success" -and ($_.Operation -like "*Activate*" -or $_.OperationType -eq "Assign") } - + # Get the most recent activation for this member $recentActivation = $memberAuditLogs | Sort-Object DateTime -Descending | Select-Object -First 1 - + if ($recentActivation) { $enrichedActivations += [PSCustomObject]@{ UserPrincipalName = $member.UserPrincipalName @@ -2691,7 +1230,7 @@ function Get-GroupActivationDetails { } } } - + return $enrichedActivations } @@ -2755,12 +1294,12 @@ function Invoke-EntraAdminRolesReportCore { try { Write-Host "INFO: Collecting PIM eligible role assignments..." -ForegroundColor Cyan $eligibleRoles = Invoke-GraphRequestWithPaging -Uri "beta/roleManagement/directory/roleEligibilitySchedules?`$expand=roleDefinition,principal" -Method Get - + if ($DebugMode) { Write-Host "debug: Eligible roles API call completed" -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: eligibleRoles type: $($eligibleRoles.GetType().Name)" -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: eligibleRoles is null: $($null -eq $eligibleRoles)" -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: eligibleRoles count: $($eligibleRoles.Count)" -ForegroundColor Magenta } - + if ($null -eq $eligibleRoles) { Write-Warning "Unable to collect PIM eligible role assignments. This MAY be due to missing Microsoft Entra ID Premium P2 license." Write-Host "INFO: Continuing without PIM eligible role assignments..." -ForegroundColor Yellow @@ -2773,7 +1312,7 @@ function Invoke-EntraAdminRolesReportCore { Write-Host "INFO: Continuing without PIM eligible role assignments..." -ForegroundColor Yellow if ($DebugMode) { Write-Host "debug: Exception details: $($_.Exception.Message)" -ForegroundColor Red } $eligibleRoles = @() # Set to empty array so the code can continue - } + } foreach ($eligibleRole in $eligibleRoles) { $eligibleRole | Add-Member -MemberType NoteProperty -Name roleDefinition1 -Value $eligibleRole.roleDefinition -Force @@ -2787,14 +1326,14 @@ function Invoke-EntraAdminRolesReportCore { try { Write-Host "INFO: Collecting activated PIM role assignments..." -ForegroundColor Cyan if ($DebugMode) { Write-Host "debug: Using API endpoint: beta/roleManagement/directory/roleAssignmentScheduleInstances?`$filter=assignmentType eq 'Activated'" -ForegroundColor Magenta } - + $roleActivations = Invoke-GraphRequestWithPaging -Uri "beta/roleManagement/directory/roleAssignmentScheduleInstances?`$filter=assignmentType eq 'Activated'" -Method Get - + if ($DebugMode) { Write-Host "debug: API call completed. Checking results..." -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: roleActivations type: $($roleActivations.GetType().Name)" -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: roleActivations is null: $($null -eq $roleActivations)" -ForegroundColor Magenta } if ($DebugMode) { Write-Host "debug: roleActivations count: $($roleActivations.Count)" -ForegroundColor Magenta } - + if ($roleActivations -and $roleActivations.Count -gt 0) { Write-Host "INFO: Found $($roleActivations.Count) activated PIM role assignments." -ForegroundColor Green # Add debug info about the activations @@ -2817,13 +1356,13 @@ function Invoke-EntraAdminRolesReportCore { if ($DebugMode) { Write-Host " 1. No users have currently activated PIM roles" -ForegroundColor Yellow } if ($DebugMode) { Write-Host " 2. Permission issues preventing access to activation data" -ForegroundColor Yellow } if ($DebugMode) { Write-Host " 3. API endpoint or filter syntax issues" -ForegroundColor Yellow } - + # Try alternative API call without filter to see if we get any data if ($DebugMode) { Write-Host "debug: Trying alternative API call without filter..." -ForegroundColor Magenta } try { $allInstances = Invoke-GraphRequestWithPaging -Uri "beta/roleManagement/directory/roleAssignmentScheduleInstances" -Method Get | Select-Object -First 10 if ($DebugMode) { Write-Host "debug: Alternative call returned $($allInstances.Count) items (showing first 10)" -ForegroundColor Magenta } - + if ($allInstances -and $allInstances.Count -gt 0) { if ($DebugMode) { Write-Host "debug: Sample of all roleAssignmentScheduleInstances:" -ForegroundColor Magenta } if ($DebugMode) { @@ -2831,17 +1370,17 @@ function Invoke-EntraAdminRolesReportCore { Write-Host " Instance - Assignment Type: $($instance.assignmentType), Principal: $($instance.principalId), Role: $($instance.roleDefinitionId)" -ForegroundColor Cyan } } - + # Check if any of these are activated $activatedFromAll = $allInstances | Where-Object { $_.assignmentType -eq "Activated" } if ($DebugMode) { Write-Host "debug: Found $($activatedFromAll.Count) activated assignments in unfiltered results" -ForegroundColor Magenta } - + if ($activatedFromAll.Count -gt 0) { if ($DebugMode) { Write-Host "debug: Using activated assignments from unfiltered results" -ForegroundColor Green } $roleActivations = $activatedFromAll } } - + # Also try another endpoint that might contain activation data if ($DebugMode) { Write-Host "debug: Trying roleAssignmentSchedules endpoint..." -ForegroundColor Magenta } $schedules = Invoke-GraphRequestWithPaging -Uri "beta/roleManagement/directory/roleAssignmentSchedules" -Method Get | Select-Object -First 5 @@ -2851,11 +1390,11 @@ function Invoke-EntraAdminRolesReportCore { Write-Host " Schedule - Assignment Type: $($schedule.assignmentType), Principal: $($schedule.principalId), Status: $($schedule.status)" -ForegroundColor Cyan } } - + } catch { if ($DebugMode) { Write-Host "debug: Alternative API calls also failed: $_" -ForegroundColor Red } } - + $roleActivations = @() } } catch { @@ -2865,13 +1404,13 @@ function Invoke-EntraAdminRolesReportCore { $roleActivations = @() } - if (!$roles) { + if (!$roles) { if ($verbose) { Write-Verbose "No role assignments found, exiting..." } else { Write-Host "INFO: No role assignments found" -ForegroundColor Red } - return + return } $Report = @() @@ -2896,7 +1435,7 @@ function Invoke-EntraAdminRolesReportCore { '#microsoft.graph.user' { $principalType = "User" $Principal = $role.principal.userPrincipalName - + if ($null -eq $role.principal.accountEnabled) { $AccountStatus = "N/A" } elseif ($role.principal.accountEnabled -eq $true) { @@ -2919,7 +1458,7 @@ function Invoke-EntraAdminRolesReportCore { '#microsoft.graph.servicePrincipal' { $principalType = "Service Principal" $Principal = $role.principal.id - + if ($null -eq $role.principal.accountEnabled) { $AccountStatus = "N/A" } elseif ($role.principal.accountEnabled -eq $true) { @@ -2942,19 +1481,19 @@ function Invoke-EntraAdminRolesReportCore { # Decide the role assignment type based on the role eligibility schedule if ($role.status) { $status = "Eligible" - + # Initialize activated members array (only for groups with actual activations) $activatedMembers = @() - + # Check for activated assignments - only for individual users, not groups if ($roleActivations.Count -gt 0 -and $principalType -eq "User") { # Look for user activations in the roleActivations data - $userActivation = $roleActivations | Where-Object { - $_.roleDefinitionId -eq $role.roleDefinitionId -and + $userActivation = $roleActivations | Where-Object { + $_.roleDefinitionId -eq $role.roleDefinitionId -and $_.principalId -eq $role.principalId -and $_.directoryScopeId -eq $role.directoryScopeId } - + if ($userActivation) { $status = "Eligible (Active)" } @@ -2962,82 +1501,82 @@ function Invoke-EntraAdminRolesReportCore { # For groups, collect which members have activated this role using roleActivations data elseif ($roleActivations.Count -gt 0 -and $principalType -eq "Group") { # First check if there are any activations for this specific role+scope combination - $roleSpecificActivationsPreCheck = $roleActivations | Where-Object { + $roleSpecificActivationsPreCheck = $roleActivations | Where-Object { $_.roleDefinitionId -eq $role.roleDefinitionId -and $_.directoryScopeId -eq $role.directoryScopeId } - + # Only proceed if there are potential activations for this role if ($roleSpecificActivationsPreCheck.Count -gt 0) { if ($DebugMode) { Write-Host "debug: Processing group $($role.principal.displayName) for role $assignedRole" -ForegroundColor Yellow } if ($DebugMode) { Write-Host "debug: Group ID: $($role.principal.id)" -ForegroundColor Yellow } if ($DebugMode) { Write-Host "debug: Role Definition ID: $($role.roleDefinitionId)" -ForegroundColor Yellow } if ($DebugMode) { Write-Host "debug: Directory Scope ID: $($role.directoryScopeId)" -ForegroundColor Yellow } - + # Get members of this specific group $groupMembers = $securityGroupMembers[$role.principal.id] if ($DebugMode) { Write-Host "debug: Group members found: $($null -ne $groupMembers)" -ForegroundColor Yellow } - + if ($groupMembers -and $groupMembers.Members) { if ($DebugMode) { Write-Host "debug: Group has $($groupMembers.Members.Count) members" -ForegroundColor Yellow } - + # Show first few group members for debugging if ($DebugMode) { foreach ($member in ($groupMembers.Members | Select-Object -First 3)) { Write-Host "debug: Group member: $($member.UserPrincipalName) (ID: $($member.Id))" -ForegroundColor Cyan } } - + # Look for activations for this specific role definition - $roleSpecificActivations = $roleActivations | Where-Object { + $roleSpecificActivations = $roleActivations | Where-Object { $_.roleDefinitionId -eq $role.roleDefinitionId -and $_.directoryScopeId -eq $role.directoryScopeId # Remove the memberType filter to catch all activations } - + if ($DebugMode) { Write-Host "debug: Found $($roleSpecificActivations.Count) role activations for role $assignedRole" -ForegroundColor Yellow } - + # If no activations found, try broader search if ($roleSpecificActivations.Count -eq 0) { if ($DebugMode) { Write-Host "debug: No activations found with exact match, trying broader search..." -ForegroundColor Yellow } - + # Try without directory scope restriction - $broadActivations = $roleActivations | Where-Object { + $broadActivations = $roleActivations | Where-Object { $_.roleDefinitionId -eq $role.roleDefinitionId } if ($DebugMode) { Write-Host "debug: Found $($broadActivations.Count) activations for this role (any scope)" -ForegroundColor Yellow } - + # Try looking for any activations involving group members - $memberActivations = $roleActivations | Where-Object { + $memberActivations = $roleActivations | Where-Object { $activation = $_ $groupMembers.Members | Where-Object { $_.Id -eq $activation.principalId } } if ($DebugMode) { Write-Host "debug: Found $($memberActivations.Count) activations involving group members (any role)" -ForegroundColor Yellow } } - + foreach ($activation in $roleSpecificActivations) { if ($DebugMode) { Write-Host "debug: Checking activation for principal $($activation.principalId)" -ForegroundColor Cyan } if ($DebugMode) { Write-Host "debug: Activation start time: $($activation.startDateTime) (type: $($activation.startDateTime.GetType().Name))" -ForegroundColor Cyan } if ($DebugMode) { Write-Host "debug: Activation end time: $($activation.endDateTime) (type: $($activation.endDateTime.GetType().Name))" -ForegroundColor Cyan } - + # Check if the activation's principal is a member of this group - $targetUser = $groupMembers.Members | Where-Object { + $targetUser = $groupMembers.Members | Where-Object { $_.Id -eq $activation.principalId } - + if ($targetUser) { if ($DebugMode) { Write-Host "debug: Found matching user: $($targetUser.UserPrincipalName)" -ForegroundColor Green } - + # Check if we already have this activation recorded - $existingActivation = $activatedMembers | Where-Object { - $_.UserId -eq $targetUser.Id + $existingActivation = $activatedMembers | Where-Object { + $_.UserId -eq $targetUser.Id } - + if (-not $existingActivation) { # Calculate end time and duration with robust datetime parsing $startTime = $null $endTime = $null - + if ($activation.startDateTime) { try { # Try different parsing approaches @@ -3056,7 +1595,7 @@ function Invoke-EntraAdminRolesReportCore { $startTime = $null } } - + if ($activation.endDateTime) { try { # Try different parsing approaches @@ -3075,8 +1614,8 @@ function Invoke-EntraAdminRolesReportCore { $endTime = $null } } - - $duration = if ($startTime -and $endTime) { + + $duration = if ($startTime -and $endTime) { $timeSpan = $endTime - $startTime if ($timeSpan.TotalDays -ge 1) { "$([math]::Floor($timeSpan.TotalDays)) days, $($timeSpan.Hours) hours" @@ -3084,7 +1623,7 @@ function Invoke-EntraAdminRolesReportCore { "$($timeSpan.Hours) hours, $($timeSpan.Minutes) minutes" } } else { "N/A" } - + $activatedMembers += [PSCustomObject]@{ UserPrincipalName = $targetUser.UserPrincipalName DisplayName = $targetUser.DisplayName @@ -3108,83 +1647,83 @@ function Invoke-EntraAdminRolesReportCore { } } if ($DebugMode) { Write-Host "debug: Final activated members count: $(@($activatedMembers).Count)" -ForegroundColor Magenta } - + # Update status if we found activated members if (@($activatedMembers).Count -gt 0) { $status = "Eligible (Active)" if ($DebugMode) { Write-Host "debug: Group marked as Eligible (Active) due to activated members" -ForegroundColor Green } } - + # If no activated members found through roleActivations, try using PIM audit logs as fallback if (@($activatedMembers).Count -eq 0) { if ($DebugMode) { Write-Host "debug: No activated members found via roleActivations, trying enhanced PIM audit logs search..." -ForegroundColor Yellow } - + # Get PIM audit logs if not already collected if (-not $PIMAuditLogsReport) { if ($DebugMode) { Write-Host "debug: Collecting PIM audit logs for activation detection..." -ForegroundColor Yellow } $PIMAuditLogsReport = Get-PIMAuditLogs } - + if ($PIMAuditLogsReport) { if ($DebugMode) { Write-Host "debug: PIM audit logs available: $($PIMAuditLogsReport.Count) entries" -ForegroundColor Yellow } - + # Look for recent activations in PIM audit logs for this role with enhanced search - $roleActivationLogs = $PIMAuditLogsReport | Where-Object { - ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and + $roleActivationLogs = $PIMAuditLogsReport | Where-Object { + ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and ($_.Role -eq $assignedRole -or $_.RoleID -eq $role.roleDefinitionId) -and $_.Result -eq "Success" -and $_.DateTime -gt (Get-Date).AddDays(-7) # Look at last 7 days for more recent data } - + if ($DebugMode) { Write-Host "debug: Found $($roleActivationLogs.Count) activation logs for role $assignedRole in last 7 days" -ForegroundColor Yellow } - + # If still no results, try broader search criteria if ($roleActivationLogs.Count -eq 0) { if ($DebugMode) { Write-Host "debug: No activations in last 7 days, trying last 30 days..." -ForegroundColor Yellow } - $roleActivationLogs = $PIMAuditLogsReport | Where-Object { - ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and + $roleActivationLogs = $PIMAuditLogsReport | Where-Object { + ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and ($_.Role -eq $assignedRole -or $_.RoleID -eq $role.roleDefinitionId) -and $_.Result -eq "Success" -and $_.DateTime -gt (Get-Date).AddDays(-30) # Look at last 30 days } if ($DebugMode) { Write-Host "debug: Found $($roleActivationLogs.Count) activation logs for role $assignedRole in last 30 days" -ForegroundColor Yellow } } - + # If still no results, try even broader search without role name restriction if ($roleActivationLogs.Count -eq 0) { if ($DebugMode) { Write-Host "debug: Trying even broader search for any activations by group members..." -ForegroundColor Yellow } - $memberActivationLogs = $PIMAuditLogsReport | Where-Object { - ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and + $memberActivationLogs = $PIMAuditLogsReport | Where-Object { + ($_.Operation -like "*Activate*" -or $_.Operation -like "*activate*" -or $_.OperationType -eq "Assign") -and $_.Result -eq "Success" -and $_.DateTime -gt (Get-Date).AddDays(-7) -and ($groupMembers.Members | Where-Object { $_.UserPrincipalName -eq $_.Target -or $_.Id -eq $_.TargetUserId }) } if ($DebugMode) { Write-Host "debug: Found $($memberActivationLogs.Count) activation logs for any group members in last 7 days" -ForegroundColor Yellow } - + # Filter these to matching roles if possible - $roleActivationLogs = $memberActivationLogs | Where-Object { - $_.Role -eq $assignedRole -or $_.RoleID -eq $role.roleDefinitionId + $roleActivationLogs = $memberActivationLogs | Where-Object { + $_.Role -eq $assignedRole -or $_.RoleID -eq $role.roleDefinitionId } if ($DebugMode) { Write-Host "debug: Of those, $($roleActivationLogs.Count) match the current role" -ForegroundColor Yellow } } - + foreach ($activationLog in $roleActivationLogs) { if ($DebugMode) { Write-Host "debug: Processing audit log activation: $($activationLog.Target) for role $($activationLog.Role)" -ForegroundColor Cyan } - + # Check if the target user is a member of this group - $targetUser = $groupMembers.Members | Where-Object { - $_.UserPrincipalName -eq $activationLog.Target -or - $_.Id -eq $activationLog.TargetUserId + $targetUser = $groupMembers.Members | Where-Object { + $_.UserPrincipalName -eq $activationLog.Target -or + $_.Id -eq $activationLog.TargetUserId } - + if ($targetUser) { if ($DebugMode) { Write-Host "debug: Found group member match: $($targetUser.UserPrincipalName)" -ForegroundColor Green } - + # Check if we already have this activation recorded - $existingActivation = $activatedMembers | Where-Object { - $_.UserPrincipalName -eq $targetUser.UserPrincipalName + $existingActivation = $activatedMembers | Where-Object { + $_.UserPrincipalName -eq $targetUser.UserPrincipalName } - + if (-not $existingActivation) { $activatedMembers += [PSCustomObject]@{ UserPrincipalName = $targetUser.UserPrincipalName @@ -3207,9 +1746,9 @@ function Invoke-EntraAdminRolesReportCore { if ($DebugMode) { Write-Host "debug: Target user $($activationLog.Target) not found in group members" -ForegroundColor Red } } } - + if ($DebugMode) { Write-Host "debug: Final activated members count after enhanced audit log check: $(@($activatedMembers).Count)" -ForegroundColor Magenta } - + # Update status if we found activated members through audit logs if (@($activatedMembers).Count -gt 0) { $status = "Eligible (Active)" @@ -3247,7 +1786,7 @@ function Invoke-EntraAdminRolesReportCore { # Create a unique key for this role assignment to detect duplicates $uniqueKey = "$Principal|$assignedRole|$($role.directoryScopeId)" - + $Reportline = [PSCustomObject]@{ "Principal" = $Principal "DisplayName" = $role.principal.displayName @@ -3258,9 +1797,9 @@ function Invoke-EntraAdminRolesReportCore { "AssignmentType" = $status "AssignmentStartDate" = $startDate "AssignmentEndDate" = $endDate - "ActivatedMembers" = if ($principalType -eq "Group" -and @($activatedMembers).Count -gt 0) { + "ActivatedMembers" = if ($principalType -eq "Group" -and @($activatedMembers).Count -gt 0) { $activatedMembers # Return activated members for groups that have them - } else { + } else { @() # Return empty array for groups without activated members or non-groups } "IsBuiltIn" = if ($role.roleDefinition.isBuiltIn) { $role.roleDefinition.isBuiltIn } elseif ($role.roleDefinition1.isBuiltIn) { $role.roleDefinition1.isBuiltIn } else { $null } @@ -3269,11 +1808,11 @@ function Invoke-EntraAdminRolesReportCore { # Check if we already have this role assignment if ($roleAssignmentTracker.ContainsKey($uniqueKey)) { $existingAssignment = $roleAssignmentTracker[$uniqueKey] - + if ($DebugMode) { Write-Host "debug: Found duplicate for $Principal - $assignedRole" -ForegroundColor Yellow } if ($DebugMode) { Write-Host "debug: Existing: $($existingAssignment.AssignmentType), Current: $status" -ForegroundColor Yellow } if ($DebugMode) { Write-Host "debug: Existing activated members: $($existingAssignment.ActivatedMembers.Count), Current: $($Reportline.ActivatedMembers.Count)" -ForegroundColor Yellow } - + # If the existing assignment is permanent and the current one is eligible, replace it # Eligible assignments take priority over permanent ones if ($existingAssignment.AssignmentType -eq "Permanent" -and ($status -eq "Eligible" -or $status -eq "Eligible (Active)")) { @@ -3283,11 +1822,11 @@ function Invoke-EntraAdminRolesReportCore { # If both are eligible, merge the activated members to preserve all activations elseif ($existingAssignment.AssignmentType -eq "Eligible" -and ($status -eq "Eligible" -or $status -eq "Eligible (Active)")) { Write-Host "MERGING: eligible assignments for: $Principal - $assignedRole" -ForegroundColor Green - + # Merge activated members from both assignments $mergedActivatedMembers = @() $mergedActivatedMembers += $existingAssignment.ActivatedMembers - + # Add new activated members that aren't already present foreach ($newMember in $Reportline.ActivatedMembers) { $existingMember = $mergedActivatedMembers | Where-Object { $_.UserId -eq $newMember.UserId } @@ -3295,11 +1834,11 @@ function Invoke-EntraAdminRolesReportCore { $mergedActivatedMembers += $newMember } } - + # Update the existing assignment with merged data $existingAssignment.ActivatedMembers = $mergedActivatedMembers $existingAssignment.AssignmentType = if (@($mergedActivatedMembers).Count -gt 0) { "Eligible (Active)" } else { "Eligible" } - + if ($DebugMode) { Write-Host "debug: Merged assignment now has $(@($mergedActivatedMembers).Count) activated members" -ForegroundColor Green } } # If both are permanent, keep the first one @@ -3334,7 +1873,7 @@ function Invoke-EntraAdminRolesReportCore { $_.AssignedRoleScopeName -eq $assignment.AssignedRoleScopeName -and ($_.AssignmentType -eq "Eligible" -or $_.AssignmentType -eq "Eligible (Active)") } - + foreach ($groupAssignment in $equivalentGroupAssignments) { # Check if the user is a member of this group $groupMembers = $securityGroupMembers[$groupAssignment.Principal] @@ -3342,17 +1881,17 @@ function Invoke-EntraAdminRolesReportCore { $userIsMember = $groupMembers.Members | Where-Object { $_.UserPrincipalName -eq $assignment.Principal } - + if ($userIsMember) { if ($DebugMode) { Write-Host "DETECTED ACTIVATION: User $($assignment.Principal) has active assignment for $($assignment."Assigned Role") through group $($groupAssignment.DisplayName)" -ForegroundColor Green } - + # Create the group key $groupKey = "$($groupAssignment.Principal)|$($assignment."Assigned Role")|$($assignment.AssignedRoleScopeName)" - + if (-not $activatedMembersByGroup.ContainsKey($groupKey)) { $activatedMembersByGroup[$groupKey] = @() } - + # Add this user as an activated member $activatedMember = [PSCustomObject]@{ UserPrincipalName = $assignment.Principal @@ -3361,7 +1900,7 @@ function Invoke-EntraAdminRolesReportCore { ActivationTime = if ($assignment.AssignmentStartDate -ne "Permanent") { $assignment.AssignmentStartDate } else { "N/A" } StartTime = if ($assignment.AssignmentStartDate -ne "Permanent") { $assignment.AssignmentStartDate } else { "N/A" } EndTime = if ($assignment.AssignmentEndDate -ne "Permanent") { $assignment.AssignmentEndDate } else { "N/A" } - Duration = if ($assignment.AssignmentStartDate -ne "Permanent" -and $assignment.AssignmentEndDate -ne "Permanent") { + Duration = if ($assignment.AssignmentStartDate -ne "Permanent" -and $assignment.AssignmentEndDate -ne "Permanent") { try { $start = [DateTime]::Parse($assignment.AssignmentStartDate) $end = [DateTime]::Parse($assignment.AssignmentEndDate) @@ -3378,12 +1917,12 @@ function Invoke-EntraAdminRolesReportCore { ActivatedBy = "N/A" Justification = "N/A" } - + # Check if this user is already in the list for this group - $existingMember = $activatedMembersByGroup[$groupKey] | Where-Object { - $_.UserPrincipalName -eq $activatedMember.UserPrincipalName + $existingMember = $activatedMembersByGroup[$groupKey] | Where-Object { + $_.UserPrincipalName -eq $activatedMember.UserPrincipalName } - + if (-not $existingMember) { $activatedMembersByGroup[$groupKey] += $activatedMember # Mark this user assignment for removal since it's represented in the group @@ -3393,7 +1932,7 @@ function Invoke-EntraAdminRolesReportCore { } } } - + # Also check for direct PIM activations (user assignments that appear but are actually activated PIM roles) # If this user has an "Permanent" assignment but there are role activations, it might be an activated PIM role if ($assignment.AssignmentType -eq "Permanent" -and $roleActivations.Count -gt 0) { @@ -3402,7 +1941,7 @@ function Invoke-EntraAdminRolesReportCore { $_.principalId -eq $assignment.Principal -and $_.assignmentType -eq "Activated" } - + if ($userActivation) { # This user assignment is actually an activated PIM role, mark it for removal $userAssignmentsToRemove += $assignment @@ -3415,32 +1954,32 @@ function Invoke-EntraAdminRolesReportCore { # Now update the group assignments with the detected activated members foreach ($groupAssignment in $report | Where-Object { $_.PrincipalType -eq "Group" }) { $groupKey = "$($groupAssignment.Principal)|$($groupAssignment."Assigned Role")|$($groupAssignment.AssignedRoleScopeName)" - + if ($activatedMembersByGroup.ContainsKey($groupKey)) { $detectedMembers = $activatedMembersByGroup[$groupKey] - + # Merge with any existing activated members (from the original PIM API detection) $allActivatedMembers = @() - + # Add existing activated members first if ($groupAssignment.ActivatedMembers -and $groupAssignment.ActivatedMembers.Count -gt 0) { $allActivatedMembers += $groupAssignment.ActivatedMembers } - + # Add newly detected members if they're not already there foreach ($detectedMember in $detectedMembers) { - $existingMember = $allActivatedMembers | Where-Object { - $_.UserPrincipalName -eq $detectedMember.UserPrincipalName + $existingMember = $allActivatedMembers | Where-Object { + $_.UserPrincipalName -eq $detectedMember.UserPrincipalName } if (-not $existingMember) { $allActivatedMembers += $detectedMember } } - + # Update the group assignment $groupAssignment.ActivatedMembers = $allActivatedMembers $groupAssignment.AssignmentType = if (@($allActivatedMembers).Count -gt 0) { "Eligible (Active)" } else { $groupAssignment.AssignmentType } - + if ($DebugMode) { Write-Host "Updated group $($groupAssignment.DisplayName) with $(@($allActivatedMembers).Count) total activated members" -ForegroundColor Green } } } @@ -3448,7 +1987,7 @@ function Invoke-EntraAdminRolesReportCore { # Remove user assignments that are actually activated PIM roles (to avoid duplicates) if (@($userAssignmentsToRemove).Count -gt 0) { if ($DebugMode) { Write-Host "INFO: Removing $(@($userAssignmentsToRemove).Count) user assignments that are activated PIM roles (to avoid duplicates)..." -ForegroundColor Cyan } - + $originalCount = $report.Count $report = $report | Where-Object { $currentAssignment = $_ @@ -3459,7 +1998,7 @@ function Invoke-EntraAdminRolesReportCore { }) } $newCount = $report.Count - + if ($DebugMode) { Write-Host "debug: Removed $($originalCount - $newCount) user assignments from report (activated PIM roles)" -ForegroundColor Green } } From a21fee004b32107dc76affbffde800f067a62fbf Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 22:02:57 +0200 Subject: [PATCH 05/11] refactor: migrate M365 License report to shared brand template Replaces ~3200 lines of inline HTML/CSS/JS with a call to New-RKSolutionsReportTemplate. Also fixes duplicated function body in Invoke-M365LicenseReportCore. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/M365License.ps1 | 3915 +++----------------------------- 1 file changed, 341 insertions(+), 3574 deletions(-) diff --git a/module/Private/M365License.ps1 b/module/Private/M365License.ps1 index f0e9a82..9421021 100644 --- a/module/Private/M365License.ps1 +++ b/module/Private/M365License.ps1 @@ -4,13 +4,13 @@ function New-HTMLReport { param( [Parameter(Mandatory = $true)] [string]$Organization, - + [Parameter(Mandatory = $true)] [array]$Report, - + [Parameter(Mandatory = $true)] [array]$SubscriptionOverview, - + [Parameter(Mandatory = $false)] [string]$ExportPath ) @@ -31,1486 +31,26 @@ function New-HTMLReport { $inheritedLicenses = ($Report | Where-Object { $_.AssignmentType -eq "Inherited" }).Count $bothLicenses = ($Report | Where-Object { $_.AssignmentType -eq "Both" }).Count $DisabledUsersWithLicenses = ($Report | Where-Object { $_.AccountEnabled -eq "No" }).Count - - # Create HTML Template with DataTables - $htmlTemplate = @' - - - - - - $Organization M365 Licensing Report - - - - - - - - - - - - - - - - - - - - -
-
- -
- -
- -
-
- -
-
-
- -

$Organization M365 Licensing Report

-
-
- Report generated on: $ReportDate -
-
- -
-
-
- -

Direct Licenses

-
$directLicenses
-
-
-
-
- -

Inherited Licenses

-
$inheritedLicenses
-
-
-
-
- -

Both (Direct + Inherited)

-
$bothLicenses
-
-
-
-
- -

Disabled Users with Licenses

-
$DisabledUsersWithLicenses
-
-
-
- -
-
Filter Options
- -
-
-
- - -
-
-
-
- - -
-
-
- -
- - -
- -
- -
- - - - - -
-
- -
-
- - -
-
- -
-
-
- -
-
-
- License Assignment -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - {{TABLE_DATA}} - -
Display NameUser Principal NameAccount StatusLast Successful Sign InLicenseAssignment TypeInheritance Details
-
-
-
- -
-
-
- Subscription Overview -
-
- -

Show all entries

-
-
-
-
- - - - - - - - - - - - - - {{SUBSCRIPTION_DATA}} - -
SubscriptionCreated DateEnd DateLicense StatusConsumed UnitsTotal LicensesAvailable Licenses
-
-
-
-
- -
-

Generated by Roy Klooster - RK Solutions

-
- - - - -'@ # Generate table rows for user licenses $tableRows = "" foreach ($item in $Report) { $accountStatusClass = if ($item.AccountEnabled -eq "No") { 'class="table-danger"' } else { '' } - + $assignmentTypeBadge = switch ($item.AssignmentType) { - "Direct" { 'Direct' } - "Inherited" { 'Inherited' } - "Both" { 'Both' } - default { 'Unknown' } + "Direct" { 'Direct' } + "Inherited" { 'Inherited' } + "Both" { 'Both' } + default { 'Unknown' } } - + $accountStatus = if ($item.AccountEnabled -eq "Yes") { - 'Enabled' + 'Enabled' } else { - 'Disabled' + 'Disabled' } - + $tableRows += @" $($item.DisplayName) @@ -1527,30 +67,30 @@ function New-HTMLReport { # Generate table rows for subscription overview $subscriptionRows = "" foreach ($item in $SubscriptionOverview) { - $availabilityPercentage = if ($item.TotalLicenses -ne 0) { - [Math]::Round(($item.AvailableLicenses / $item.TotalLicenses) * 100) + $availabilityPercentage = if ($item.TotalLicenses -ne 0) { + [Math]::Round(($item.AvailableLicenses / $item.TotalLicenses) * 100) } - else { - 0 + else { + 0 } - + $availabilityBadge = if ($availabilityPercentage -lt 10) { - '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' + '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' } elseif ($availabilityPercentage -lt 20) { - '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' + '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' } else { - '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' + '' + $item.AvailableLicenses + ' (' + $availabilityPercentage + '%)' } - + $licenseStatusBadge = if ($item.LicenseStatus -eq "Enabled") { - 'Enabled' + 'Enabled' } else { - 'Disabled' + 'Disabled' } - + $subscriptionRows += @" $($item.FriendlyName) @@ -1564,2087 +104,335 @@ function New-HTMLReport { "@ } - # Get current date for report - $currentDate = Get-Date -Format "dd-MM-yyyy HH:mm" - - # Replace placeholders in template with actual values - $htmlContent = $htmlTemplate.Replace('$Organization', $Organization) - $htmlContent = $htmlContent.Replace('$ReportDate', $currentDate) - $htmlContent = $htmlContent.Replace('$directLicenses', $directLicenses) - $htmlContent = $htmlContent.Replace('$inheritedLicenses', $inheritedLicenses) - $htmlContent = $htmlContent.Replace('$bothLicenses', $bothLicenses) - $htmlContent = $htmlContent.Replace('$DisabledUsersWithLicenses', $DisabledUsersWithLicenses) - $htmlContent = $htmlContent.Replace('{{TABLE_DATA}}', $tableRows) - $htmlContent = $htmlContent.Replace('{{SUBSCRIPTION_DATA}}', $subscriptionRows) - - # Add additional CSS for dark mode pagination - $darkModePaginationCss = @" - -"@ - - # Insert the dark mode pagination CSS before the tag - $htmlContent = $htmlContent.Replace('', "$darkModePaginationCss`n") - # Export to HTML file - $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 + $disabledUsersRows += @" + + $($item.DisplayName) + $($item.UserPrincipalName) + Disabled + $($item.LastSuccessfulSignIn) + $($item.AssignedLicensesFriendlyName) + $assignmentTypeBadge + $($item.Inheritance) + +"@ + } - # Set script-scoped variable for email attachment - $script:ExportPath = $ExportPath + # Get current date for report + $CurrentDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + + # Build stat tiles HTML + $statsCardsHtml = @" +
+
DIRECT
+
$directLicenses
+
Direct license assignments
+
+
+
INHERITED
+
$inheritedLicenses
+
Group-based assignments
+
+
+
BOTH
+
$bothLicenses
+
Direct + Inherited
+
+
+
DISABLED
+
$DisabledUsersWithLicenses
+
Disabled users with licenses
+
+"@ - Write-Host "All actions completed successfully." -ForegroundColor Cyan - Write-Host "Report saved to: $ExportPath" -ForegroundColor Cyan + # Build body content HTML (tabs + panels + filter containers + tables + script) + $bodyContentHtml = @" + +
+ + + +
- # Open the HTML file (cross-platform: Invoke-Item uses default handler; fallback so script does not fail in headless env) - if (-not $SendEmail) { - try { - Invoke-Item $ExportPath -ErrorAction Stop - } catch { - Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow - } - } -} - -function Get-LicenseIdentifiers { - $header = 'Product_Display_Name', 'String_Id', 'GUID', 'Service_Plan_Name', 'Service_Plan_Id', 'Service_Plans_Included_Friendly_Names' - $params = @{ - Method = 'Get' - Uri = "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv" - } - $Identifiers = Invoke-RestMethod @params | ConvertFrom-Csv -Header $header | - ForEach-Object { - [PSCustomObject]@{ - GUID = $_.GUID - String_Id = $_.String_Id - Product_Display_Name = $_.Product_Display_Name - } - } - return $Identifiers | Select-Object -Skip 1 -} - - -function Invoke-M365LicenseReportCore { - param( - [Parameter(Mandatory=$false)] [switch] $SendEmail, - [Parameter(Mandatory=$false)] [string[]] $Recipient, - [Parameter(Mandatory=$false)] [string] $From, - [Parameter(Mandatory=$false)] [string] $ExportPath - ) - # CODE - - # Get Organization Name - $Organization = Invoke-MgGraphRequest -Uri "beta/organization" -OutputType PSObject | Select-Object -Expand Value | Select-Object -ExpandProperty DisplayName - - # Get product identifiers - $Identifiers = Get-LicenseIdentifiers - - # Select all SKUs with friendly display name - [array]$SKU_friendly = $Identifiers | Select-Object GUID, String_Id, Product_Display_Name -Unique - - # NEW CLOUD LICENSING API: Get allotments with subscription details in one call (Beta API) - # This replaces the previous two separate calls to subscribedSkus and directory/subscriptions - Write-Host "INFO: Retrieving allotment and subscription data using Cloud Licensing API..." -ForegroundColor Cyan - - try { - # Try new Cloud Licensing API first - # Note: subscriptions is included by default, no need to expand - [Array]$allotments = Invoke-GraphRequestWithPaging -Uri "beta/admin/cloudLicensing/allotments?`$select=id,allottedUnits,consumedUnits,skuId,skuPartNumber,assignableTo,subscriptions" - - if (-not $allotments -or $allotments.Count -eq 0) { - throw "Allotments API returned empty results" - } - - $useCloudLicensingAPI = $true - Write-Host "INFO: Successfully retrieved data from Cloud Licensing API" -ForegroundColor Green - - # Diagnostic: Show what properties are available in first subscription (if verbose) - if ($VerbosePreference -eq 'Continue' -and $allotments.Count -gt 0) { - $firstAllotment = $allotments[0] - if ($firstAllotment.subscriptions -and $firstAllotment.subscriptions.Count -gt 0) { - $firstSub = $firstAllotment.subscriptions[0] - Write-Verbose "Subscription properties available: $($firstSub.PSObject.Properties.Name -join ', ')" - } - } - - # Always show diagnostic info about subscription structure (helps with troubleshooting) - Write-Host "INFO: Found $($allotments.Count) allotments" -ForegroundColor Cyan - $totalSubscriptions = ($allotments | ForEach-Object { if ($_.subscriptions) { $_.subscriptions.Count } else { 0 } } | Measure-Object -Sum).Sum - Write-Host "INFO: Total subscriptions across all allotments: $totalSubscriptions" -ForegroundColor Cyan - - # Supplementary call to get dates from legacy API if needed - # The allotments API includes startDate and nextLifecycleDate, but we fetch - # from legacy API as a fallback in case subscription IDs don't match perfectly - Write-Host "INFO: Retrieving subscription dates as fallback..." -ForegroundColor Cyan - [Array]$LegacySubscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions?`$select=id,createdDateTime,nextLifecycleDateTime,skuId" -OutputType PSObject | - Select-Object -ExpandProperty Value - - # Create lookup table for quick access to both created and end dates - $subscriptionDateLookup = @{} - foreach ($legacySub in $LegacySubscriptions) { - if ($legacySub.id) { - $subscriptionDateLookup[$legacySub.id] = @{ - CreatedDate = $legacySub.createdDateTime - EndDate = $legacySub.nextLifecycleDateTime - } - } - } - - Write-Host "INFO: Created lookup table with $($subscriptionDateLookup.Count) subscription dates" -ForegroundColor Cyan - if ($VerbosePreference -eq 'Continue' -and $subscriptionDateLookup.Count -gt 0) { - Write-Verbose "Sample lookup IDs: $(($subscriptionDateLookup.Keys | Select-Object -First 3) -join ', ')" - $firstId = $subscriptionDateLookup.Keys | Select-Object -First 1 - if ($firstId) { - Write-Verbose "Sample data for ID $firstId - CreatedDate: $($subscriptionDateLookup[$firstId].CreatedDate), EndDate: $($subscriptionDateLookup[$firstId].EndDate)" - } - } - - # Show how many subscriptions have end dates - $subsWithEndDates = ($LegacySubscriptions | Where-Object { $_.nextLifecycleDateTime }).Count - $subsWithoutEndDates = $LegacySubscriptions.Count - $subsWithEndDates - Write-Host "INFO: Subscriptions with end dates: $subsWithEndDates, without end dates: $subsWithoutEndDates" -ForegroundColor Cyan - } - catch { - # Fallback to legacy API if Cloud Licensing API fails - Write-Host "WARNING: Cloud Licensing API failed, falling back to legacy API. Error: $($_.Exception.Message)" -ForegroundColor Yellow - $useCloudLicensingAPI = $false - - # Legacy API calls - [Array]$Skus = Invoke-MgGraphRequest -Uri "Beta/subscribedSkus" -OutputType PSObject | - Select-Object -ExpandProperty Value - [Array]$Subscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions" -OutputType PSObject | - Select-Object -ExpandProperty Value - } - - # Create an overview of subscriptions with their end date - $SubscriptionOverview = @() - - if ($useCloudLicensingAPI) { - # NEW: Process allotments from Cloud Licensing API - $datesFoundCount = 0 - $datesNotFoundCount = 0 - - # Group allotments by SKU to combine duplicate licenses - $allotmentsBySkuId = @{} - - foreach ($allotment in $allotments) { - # Get friendly name - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $allotment.skuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = if ($allotment.skuPartNumber) { $allotment.skuPartNumber } else { "Unknown License ($($allotment.skuId))" } - } - - # Initialize SKU group if not exists - if (-not $allotmentsBySkuId.ContainsKey($allotment.skuId)) { - $allotmentsBySkuId[$allotment.skuId] = @{ - FriendlyName = $friendlyName - SKUPartNumber = $allotment.skuPartNumber - AssignableTo = $allotment.assignableTo - TotalLicenses = 0 - ConsumedUnits = 0 - CreatedDates = @() - EndDates = @() - SubscriptionIds = @() - } - } - - # Aggregate license counts - $allotmentsBySkuId[$allotment.skuId].TotalLicenses += if ($allotment.allottedUnits) { $allotment.allottedUnits } else { 0 } - $allotmentsBySkuId[$allotment.skuId].ConsumedUnits += if ($allotment.consumedUnits) { $allotment.consumedUnits } else { 0 } - - # Process subscriptions to collect dates - if ($allotment.subscriptions -and $allotment.subscriptions.Count -gt 0) { - foreach ($subscription in $allotment.subscriptions) { - if ($subscription.id) { - $allotmentsBySkuId[$allotment.skuId].SubscriptionIds += $subscription.id - } - - # Resolve created/start date - $subCreated = $null - if ($subscription.startDate) { $subCreated = $subscription.startDate } - elseif ($subscription.createdDateTime) { $subCreated = $subscription.createdDateTime } - elseif ($subscription.createdDate) { $subCreated = $subscription.createdDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subCreated = $subscriptionDateLookup[$subscription.id].CreatedDate - } - - if ($subCreated) { - $datesFoundCount++ - $d = try { [DateTime]$subCreated } catch { $null } - if ($d) { - $allotmentsBySkuId[$allotment.skuId].CreatedDates += $d - } - } else { - $datesNotFoundCount++ - } - - # Resolve end/lifecycle date - $subEnd = $null - if ($subscription.nextLifecycleDate) { $subEnd = $subscription.nextLifecycleDate } - elseif ($subscription.nextLifecycleDateTime) { $subEnd = $subscription.nextLifecycleDateTime } - elseif ($subscription.endDate) { $subEnd = $subscription.endDate } - elseif ($subscription.expiryDate) { $subEnd = $subscription.expiryDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subEnd = $subscriptionDateLookup[$subscription.id].EndDate - } - - if ($subEnd -and $subEnd -ne "No end date found") { - $e = try { [DateTime]$subEnd } catch { $null } - if ($e) { - $allotmentsBySkuId[$allotment.skuId].EndDates += $e - } - } - } - } - } - - # Now create subscription overview with one row per SKU - foreach ($skuId in $allotmentsBySkuId.Keys) { - $skuData = $allotmentsBySkuId[$skuId] - - # Get earliest created date - $createdDate = if ($skuData.CreatedDates.Count -gt 0) { - ($skuData.CreatedDates | Measure-Object -Minimum).Minimum - } else { $null } - - $formattedCreatedDate = if ($createdDate) { - try { Get-Date $createdDate -Format "dd-MM-yyyy HH:mm" } - catch { $createdDate.ToString() } - } else { "Unknown" } - - # Get latest end date - $endDate = if ($skuData.EndDates.Count -gt 0) { - ($skuData.EndDates | Measure-Object -Maximum).Maximum - } else { $null } - - if (-not $endDate) { - $endDate = "No end date found" - } - - $formattedEndDate = if ($endDate -ne "No end date found") { - try { Get-Date $endDate -Format "dd-MM-yyyy HH:mm" } - catch { $endDate } - } else { $endDate } - - # Determine license status - $licenseStatus = "Enabled" - if ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - $licenseStatus = if ($dateObj -gt (Get-Date)) { "Enabled" } else { "Disabled" } - } catch { - $licenseStatus = "Unknown" - } - } - - $availableLicenses = $skuData.TotalLicenses - $skuData.ConsumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = ($skuData.SubscriptionIds | Select-Object -First 1) - FriendlyName = $skuData.FriendlyName - SKUPartNumber = $skuData.SKUPartNumber - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $skuData.ConsumedUnits - TotalLicenses = $skuData.TotalLicenses - AvailableLicenses = $availableLicenses - AssignableTo = $skuData.AssignableTo - } - } - - # Show summary of date matching - Write-Host "INFO: Created dates - Found: $datesFoundCount, Not Found: $datesNotFoundCount" -ForegroundColor Cyan - } - else { - # LEGACY: Process subscriptions from old API - foreach ($subscription in $Subscriptions) { - $sku = $Skus | Where-Object { $_.SkuId -eq $subscription.SkuId } - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $sku.SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($($sku.SkuId))" - } - - $endDate = if ($null -eq $subscription.NextLifecycleDateTime) { - "No end date found" - } else { - $subscription.NextLifecycleDateTime - } - - # Format dates - $formattedCreatedDate = if ($subscription.CreatedDateTime -is [DateTime]) { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } elseif ($subscription.CreatedDateTime) { - try { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } catch { - $subscription.CreatedDateTime - } - } else { - "Unknown" - } - - $formattedEndDate = if ($endDate -is [DateTime]) { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } elseif ($endDate -and $endDate -ne "No end date found") { - try { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } catch { - $endDate - } - } else { - $endDate - } - - # Determine license status - $licenseStatus = if ($endDate -eq "No end date found") { - "Enabled" - } elseif ($endDate -is [DateTime] -and $endDate -gt (Get-Date)) { - "Enabled" - } elseif ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - if ($dateObj -gt (Get-Date)) { - "Enabled" - } else { - "Disabled" - } - } catch { - "Unknown" - } - } else { - "Unknown" - } - - # Calculate available licenses - $totalLicenses = if ($subscription.TotalLicenses) { $subscription.TotalLicenses } else { 0 } - $consumedUnits = if ($sku.ConsumedUnits) { $sku.ConsumedUnits } else { 0 } - $availableLicenses = $totalLicenses - $consumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = $subscription.Id - FriendlyName = $friendlyName - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $consumedUnits - TotalLicenses = $totalLicenses - AvailableLicenses = $availableLicenses - } - } - } - - # Output the overview - Write-Host "INFO: Generating subscription overview..." -ForegroundColor Cyan - - # Get all users with licenses - using paging to ensure all results are retrieved - Write-Host "INFO: Retrieving user license data..." -ForegroundColor Cyan - $users = Invoke-GraphRequestWithPaging -Uri "beta/users?`$select=UserPrincipalName,LicenseAssignmentStates,DisplayName,AccountEnabled,AssignedLicenses,signInActivity&`$top=999" - - # Get all groups with their licenses - Write-Host "INFO: Retrieving group license data..." -ForegroundColor Cyan - $Groups = Invoke-GraphRequestWithPaging -Uri "beta/groups?`$select=id,displayName,assignedLicenses&`$top=999" - $groupsWithLicenses = @() - - # Loop through each group and check if it has any licenses assigned - Write-Host "INFO: Checking groups for licenses..." -ForegroundColor Cyan - foreach ($group in $Groups) { - if ($group.assignedLicenses -and $group.assignedLicenses.Count -gt 0) { - $groupData = [PSCustomObject]@{ - ObjectId = $group.id - DisplayName = $group.displayName - Licenses = $group.assignedLicenses - } - $groupsWithLicenses += $groupData - } - } - - # Initialize the report array - $Report = @() - - # Process user license data - $totalUsers = $users.Count - $currentIndex = 0 - - foreach ($user in $users) { - $currentIndex++ - Write-Progress -Activity "Processing users" -Status "Processing $currentIndex of $totalUsers" -PercentComplete (($currentIndex / $totalUsers) * 100) - - # Skip users with no license assignment states - if (-not $user.LicenseAssignmentStates) { - continue - } - - # Group licenses by SkuId to detect both direct and inherited assignments - $licensesBySkuId = @{} - - foreach ($license in $user.LicenseAssignmentStates) { - $SkuId = $license.SkuId - $AssignedByGroup = $license.AssignedByGroup - - if (-not $licensesBySkuId.ContainsKey($SkuId)) { - $licensesBySkuId[$SkuId] = @{ - DirectAssignment = $false - GroupAssignments = @() - } - } - - if ($null -eq $AssignedByGroup) { - $licensesBySkuId[$SkuId].DirectAssignment = $true - } - else { - $licensesBySkuId[$SkuId].GroupAssignments += $AssignedByGroup - } - } - - # Process each unique license - foreach ($SkuId in $licensesBySkuId.Keys) { - $licenseInfo = $licensesBySkuId[$SkuId] - $isDirect = $licenseInfo.DirectAssignment - $isInherited = ($licenseInfo.GroupAssignments.Count -gt 0) - - # Determine assignment type - $assignmentType = if ($isDirect -and $isInherited) { - "Both" - } - elseif ($isDirect) { - "Direct" - } - elseif ($isInherited) { - "Inherited" - } - else { - "Unknown" - } - - # Get friendly name for the license - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($SkuId)" - } - - # Get group names if inherited - $groupNames = "" - if ($isInherited) { - $groupNamesList = @() - foreach ($groupId in $licenseInfo.GroupAssignments) { - $group = $groupsWithLicenses | Where-Object { $_.ObjectId -eq $groupId } - if ($group) { - $groupNamesList += $group.DisplayName - } - else { - $groupNamesList += "Unknown Group ($groupId)" - } - } - $groupNames = $groupNamesList -join ", " - } - - # Determine inheritance description - if ($isDirect -and -not $groupNames) { - $inheritance = "Direct" - } - elseif (-not $isDirect -and $groupNames) { - $inheritance = $groupNames - } - elseif ($isDirect -and $groupNames) { - $inheritance = "Direct, $groupNames" - } - else { - $inheritance = "Unknown" - } - - # Last Login Activity (robust handling of null/invalid values) - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSignInDateTime - if ($lastSignIn -eq "No sign-in activity" -or $lastSignIn -eq "Invalid date value") { - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSuccessfulSignInDateTime - } - - # Create the license data object - $licenseData = [PSCustomObject]@{ - UserPrincipalName = $user.UserPrincipalName - DisplayName = $user.DisplayName - AccountEnabled = if ($user.AccountEnabled) { "Yes" } else { "No" } - LastSuccessfulSignIn = $lastSignIn - AssignedLicenses = $SkuId - AssignedLicensesFriendlyName = $friendlyName - Inheritance = $inheritance - AssignmentType = $assignmentType - IsDirect = $isDirect - IsInherited = $isInherited - } - - # Add to the report - $Report += $licenseData - } - } - - - # Calculate metrics for summary boxes - $script:directLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $false }).Count - $script:inheritedLicenses = ($Report | Where-Object { $_.IsInherited -eq $true -and $_.IsDirect -eq $false }).Count - $script:bothLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $true }).Count - $script:DisabledUsersWithLicenses = ($Report | Where-Object { $_.AccountEnabled -eq "No" } | Select-Object -Unique UserPrincipalName).Count - - # Output summary information - Write-Host "INFO: License Summary:" -ForegroundColor Cyan - Write-Host "Total users processed: $totalUsers" -ForegroundColor White - Write-Host "Users with licenses: $($Report | Select-Object -Unique UserPrincipalName | Measure-Object | Select-Object -ExpandProperty Count)" -ForegroundColor White - Write-Host "Direct license assignments: $script:directLicenses" -ForegroundColor White - Write-Host "Inherited license assignments: $script:inheritedLicenses" -ForegroundColor White - Write-Host "Both direct and inherited: $script:bothLicenses" -ForegroundColor White - Write-Host "Disabled users with licenses: $script:DisabledUsersWithLicenses" -ForegroundColor White - - # Export to HTML - New-HTMLReport -Organization $Organization -Report $Report -SubscriptionOverview $SubscriptionOverview -ExportPath $ExportPath - - # Send email with the report - $emailSent = $false - if ($SendEmail) { - $emailSent = # CODE - - # Get Organization Name - $Organization = Invoke-MgGraphRequest -Uri "beta/organization" -OutputType PSObject | Select-Object -Expand Value | Select-Object -ExpandProperty DisplayName - - # Get product identifiers - $Identifiers = Get-LicenseIdentifiers - - # Select all SKUs with friendly display name - [array]$SKU_friendly = $Identifiers | Select-Object GUID, String_Id, Product_Display_Name -Unique - - # NEW CLOUD LICENSING API: Get allotments with subscription details in one call (Beta API) - # This replaces the previous two separate calls to subscribedSkus and directory/subscriptions - Write-Host "INFO: Retrieving allotment and subscription data using Cloud Licensing API..." -ForegroundColor Cyan - - try { - # Try new Cloud Licensing API first - # Note: subscriptions is included by default, no need to expand - [Array]$allotments = Invoke-GraphRequestWithPaging -Uri "beta/admin/cloudLicensing/allotments?`$select=id,allottedUnits,consumedUnits,skuId,skuPartNumber,assignableTo,subscriptions" - - if (-not $allotments -or $allotments.Count -eq 0) { - throw "Allotments API returned empty results" - } - - $useCloudLicensingAPI = $true - Write-Host "INFO: Successfully retrieved data from Cloud Licensing API" -ForegroundColor Green - - # Diagnostic: Show what properties are available in first subscription (if verbose) - if ($VerbosePreference -eq 'Continue' -and $allotments.Count -gt 0) { - $firstAllotment = $allotments[0] - if ($firstAllotment.subscriptions -and $firstAllotment.subscriptions.Count -gt 0) { - $firstSub = $firstAllotment.subscriptions[0] - Write-Verbose "Subscription properties available: $($firstSub.PSObject.Properties.Name -join ', ')" - } - } - - # Always show diagnostic info about subscription structure (helps with troubleshooting) - Write-Host "INFO: Found $($allotments.Count) allotments" -ForegroundColor Cyan - $totalSubscriptions = ($allotments | ForEach-Object { if ($_.subscriptions) { $_.subscriptions.Count } else { 0 } } | Measure-Object -Sum).Sum - Write-Host "INFO: Total subscriptions across all allotments: $totalSubscriptions" -ForegroundColor Cyan - - # Supplementary call to get dates from legacy API if needed - # The allotments API includes startDate and nextLifecycleDate, but we fetch - # from legacy API as a fallback in case subscription IDs don't match perfectly - Write-Host "INFO: Retrieving subscription dates as fallback..." -ForegroundColor Cyan - [Array]$LegacySubscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions?`$select=id,createdDateTime,nextLifecycleDateTime,skuId" -OutputType PSObject | - Select-Object -ExpandProperty Value - - # Create lookup table for quick access to both created and end dates - $subscriptionDateLookup = @{} - foreach ($legacySub in $LegacySubscriptions) { - if ($legacySub.id) { - $subscriptionDateLookup[$legacySub.id] = @{ - CreatedDate = $legacySub.createdDateTime - EndDate = $legacySub.nextLifecycleDateTime - } - } - } - - Write-Host "INFO: Created lookup table with $($subscriptionDateLookup.Count) subscription dates" -ForegroundColor Cyan - if ($VerbosePreference -eq 'Continue' -and $subscriptionDateLookup.Count -gt 0) { - Write-Verbose "Sample lookup IDs: $(($subscriptionDateLookup.Keys | Select-Object -First 3) -join ', ')" - $firstId = $subscriptionDateLookup.Keys | Select-Object -First 1 - if ($firstId) { - Write-Verbose "Sample data for ID $firstId - CreatedDate: $($subscriptionDateLookup[$firstId].CreatedDate), EndDate: $($subscriptionDateLookup[$firstId].EndDate)" - } - } - - # Show how many subscriptions have end dates - $subsWithEndDates = ($LegacySubscriptions | Where-Object { $_.nextLifecycleDateTime }).Count - $subsWithoutEndDates = $LegacySubscriptions.Count - $subsWithEndDates - Write-Host "INFO: Subscriptions with end dates: $subsWithEndDates, without end dates: $subsWithoutEndDates" -ForegroundColor Cyan - } - catch { - # Fallback to legacy API if Cloud Licensing API fails - Write-Host "WARNING: Cloud Licensing API failed, falling back to legacy API. Error: $($_.Exception.Message)" -ForegroundColor Yellow - $useCloudLicensingAPI = $false - - # Legacy API calls - [Array]$Skus = Invoke-MgGraphRequest -Uri "Beta/subscribedSkus" -OutputType PSObject | - Select-Object -ExpandProperty Value - [Array]$Subscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions" -OutputType PSObject | - Select-Object -ExpandProperty Value - } - - # Create an overview of subscriptions with their end date - $SubscriptionOverview = @() - - if ($useCloudLicensingAPI) { - # NEW: Process allotments from Cloud Licensing API - $datesFoundCount = 0 - $datesNotFoundCount = 0 - - # Group allotments by SKU to combine duplicate licenses - $allotmentsBySkuId = @{} - - foreach ($allotment in $allotments) { - # Get friendly name - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $allotment.skuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = if ($allotment.skuPartNumber) { $allotment.skuPartNumber } else { "Unknown License ($($allotment.skuId))" } - } - - # Initialize SKU group if not exists - if (-not $allotmentsBySkuId.ContainsKey($allotment.skuId)) { - $allotmentsBySkuId[$allotment.skuId] = @{ - FriendlyName = $friendlyName - SKUPartNumber = $allotment.skuPartNumber - AssignableTo = $allotment.assignableTo - TotalLicenses = 0 - ConsumedUnits = 0 - CreatedDates = @() - EndDates = @() - SubscriptionIds = @() - } - } - - # Aggregate license counts - $allotmentsBySkuId[$allotment.skuId].TotalLicenses += if ($allotment.allottedUnits) { $allotment.allottedUnits } else { 0 } - $allotmentsBySkuId[$allotment.skuId].ConsumedUnits += if ($allotment.consumedUnits) { $allotment.consumedUnits } else { 0 } - - # Process subscriptions to collect dates - if ($allotment.subscriptions -and $allotment.subscriptions.Count -gt 0) { - foreach ($subscription in $allotment.subscriptions) { - if ($subscription.id) { - $allotmentsBySkuId[$allotment.skuId].SubscriptionIds += $subscription.id - } - - # Resolve created/start date - $subCreated = $null - if ($subscription.startDate) { $subCreated = $subscription.startDate } - elseif ($subscription.createdDateTime) { $subCreated = $subscription.createdDateTime } - elseif ($subscription.createdDate) { $subCreated = $subscription.createdDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subCreated = $subscriptionDateLookup[$subscription.id].CreatedDate - } - - if ($subCreated) { - $datesFoundCount++ - $d = try { [DateTime]$subCreated } catch { $null } - if ($d) { - $allotmentsBySkuId[$allotment.skuId].CreatedDates += $d - } - } else { - $datesNotFoundCount++ - } - - # Resolve end/lifecycle date - $subEnd = $null - if ($subscription.nextLifecycleDate) { $subEnd = $subscription.nextLifecycleDate } - elseif ($subscription.nextLifecycleDateTime) { $subEnd = $subscription.nextLifecycleDateTime } - elseif ($subscription.endDate) { $subEnd = $subscription.endDate } - elseif ($subscription.expiryDate) { $subEnd = $subscription.expiryDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subEnd = $subscriptionDateLookup[$subscription.id].EndDate - } - - if ($subEnd -and $subEnd -ne "No end date found") { - $e = try { [DateTime]$subEnd } catch { $null } - if ($e) { - $allotmentsBySkuId[$allotment.skuId].EndDates += $e - } - } - } - } - } - - # Now create subscription overview with one row per SKU - foreach ($skuId in $allotmentsBySkuId.Keys) { - $skuData = $allotmentsBySkuId[$skuId] - - # Get earliest created date - $createdDate = if ($skuData.CreatedDates.Count -gt 0) { - ($skuData.CreatedDates | Measure-Object -Minimum).Minimum - } else { $null } - - $formattedCreatedDate = if ($createdDate) { - try { Get-Date $createdDate -Format "dd-MM-yyyy HH:mm" } - catch { $createdDate.ToString() } - } else { "Unknown" } - - # Get latest end date - $endDate = if ($skuData.EndDates.Count -gt 0) { - ($skuData.EndDates | Measure-Object -Maximum).Maximum - } else { $null } - - if (-not $endDate) { - $endDate = "No end date found" - } - - $formattedEndDate = if ($endDate -ne "No end date found") { - try { Get-Date $endDate -Format "dd-MM-yyyy HH:mm" } - catch { $endDate } - } else { $endDate } - - # Determine license status - $licenseStatus = "Enabled" - if ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - $licenseStatus = if ($dateObj -gt (Get-Date)) { "Enabled" } else { "Disabled" } - } catch { - $licenseStatus = "Unknown" - } - } - - $availableLicenses = $skuData.TotalLicenses - $skuData.ConsumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = ($skuData.SubscriptionIds | Select-Object -First 1) - FriendlyName = $skuData.FriendlyName - SKUPartNumber = $skuData.SKUPartNumber - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $skuData.ConsumedUnits - TotalLicenses = $skuData.TotalLicenses - AvailableLicenses = $availableLicenses - AssignableTo = $skuData.AssignableTo - } - } - - # Show summary of date matching - Write-Host "INFO: Created dates - Found: $datesFoundCount, Not Found: $datesNotFoundCount" -ForegroundColor Cyan - } - else { - # LEGACY: Process subscriptions from old API - foreach ($subscription in $Subscriptions) { - $sku = $Skus | Where-Object { $_.SkuId -eq $subscription.SkuId } - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $sku.SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($($sku.SkuId))" - } - - $endDate = if ($null -eq $subscription.NextLifecycleDateTime) { - "No end date found" - } else { - $subscription.NextLifecycleDateTime - } - - # Format dates - $formattedCreatedDate = if ($subscription.CreatedDateTime -is [DateTime]) { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } elseif ($subscription.CreatedDateTime) { - try { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } catch { - $subscription.CreatedDateTime - } - } else { - "Unknown" - } - - $formattedEndDate = if ($endDate -is [DateTime]) { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } elseif ($endDate -and $endDate -ne "No end date found") { - try { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } catch { - $endDate - } - } else { - $endDate - } - - # Determine license status - $licenseStatus = if ($endDate -eq "No end date found") { - "Enabled" - } elseif ($endDate -is [DateTime] -and $endDate -gt (Get-Date)) { - "Enabled" - } elseif ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - if ($dateObj -gt (Get-Date)) { - "Enabled" - } else { - "Disabled" - } - } catch { - "Unknown" - } - } else { - "Unknown" - } - - # Calculate available licenses - $totalLicenses = if ($subscription.TotalLicenses) { $subscription.TotalLicenses } else { 0 } - $consumedUnits = if ($sku.ConsumedUnits) { $sku.ConsumedUnits } else { 0 } - $availableLicenses = $totalLicenses - $consumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = $subscription.Id - FriendlyName = $friendlyName - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $consumedUnits - TotalLicenses = $totalLicenses - AvailableLicenses = $availableLicenses - } - } - } - - # Output the overview - Write-Host "INFO: Generating subscription overview..." -ForegroundColor Cyan - - # Get all users with licenses - using paging to ensure all results are retrieved - Write-Host "INFO: Retrieving user license data..." -ForegroundColor Cyan - $users = Invoke-GraphRequestWithPaging -Uri "beta/users?`$select=UserPrincipalName,LicenseAssignmentStates,DisplayName,AccountEnabled,AssignedLicenses,signInActivity&`$top=999" - - # Get all groups with their licenses - Write-Host "INFO: Retrieving group license data..." -ForegroundColor Cyan - $Groups = Invoke-GraphRequestWithPaging -Uri "beta/groups?`$select=id,displayName,assignedLicenses&`$top=999" - $groupsWithLicenses = @() - - # Loop through each group and check if it has any licenses assigned - Write-Host "INFO: Checking groups for licenses..." -ForegroundColor Cyan - foreach ($group in $Groups) { - if ($group.assignedLicenses -and $group.assignedLicenses.Count -gt 0) { - $groupData = [PSCustomObject]@{ - ObjectId = $group.id - DisplayName = $group.displayName - Licenses = $group.assignedLicenses - } - $groupsWithLicenses += $groupData - } - } - - # Initialize the report array - $Report = @() - - # Process user license data - $totalUsers = $users.Count - $currentIndex = 0 - - foreach ($user in $users) { - $currentIndex++ - Write-Progress -Activity "Processing users" -Status "Processing $currentIndex of $totalUsers" -PercentComplete (($currentIndex / $totalUsers) * 100) - - # Skip users with no license assignment states - if (-not $user.LicenseAssignmentStates) { - continue - } - - # Group licenses by SkuId to detect both direct and inherited assignments - $licensesBySkuId = @{} - - foreach ($license in $user.LicenseAssignmentStates) { - $SkuId = $license.SkuId - $AssignedByGroup = $license.AssignedByGroup - - if (-not $licensesBySkuId.ContainsKey($SkuId)) { - $licensesBySkuId[$SkuId] = @{ - DirectAssignment = $false - GroupAssignments = @() - } - } - - if ($null -eq $AssignedByGroup) { - $licensesBySkuId[$SkuId].DirectAssignment = $true - } - else { - $licensesBySkuId[$SkuId].GroupAssignments += $AssignedByGroup - } - } - - # Process each unique license - foreach ($SkuId in $licensesBySkuId.Keys) { - $licenseInfo = $licensesBySkuId[$SkuId] - $isDirect = $licenseInfo.DirectAssignment - $isInherited = ($licenseInfo.GroupAssignments.Count -gt 0) - - # Determine assignment type - $assignmentType = if ($isDirect -and $isInherited) { - "Both" - } - elseif ($isDirect) { - "Direct" - } - elseif ($isInherited) { - "Inherited" - } - else { - "Unknown" - } - - # Get friendly name for the license - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($SkuId)" - } - - # Get group names if inherited - $groupNames = "" - if ($isInherited) { - $groupNamesList = @() - foreach ($groupId in $licenseInfo.GroupAssignments) { - $group = $groupsWithLicenses | Where-Object { $_.ObjectId -eq $groupId } - if ($group) { - $groupNamesList += $group.DisplayName - } - else { - $groupNamesList += "Unknown Group ($groupId)" - } - } - $groupNames = $groupNamesList -join ", " - } - - # Determine inheritance description - if ($isDirect -and -not $groupNames) { - $inheritance = "Direct" - } - elseif (-not $isDirect -and $groupNames) { - $inheritance = $groupNames - } - elseif ($isDirect -and $groupNames) { - $inheritance = "Direct, $groupNames" - } - else { - $inheritance = "Unknown" - } - - # Last Login Activity (robust handling of null/invalid values) - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSignInDateTime - if ($lastSignIn -eq "No sign-in activity" -or $lastSignIn -eq "Invalid date value") { - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSuccessfulSignInDateTime - } - - # Create the license data object - $licenseData = [PSCustomObject]@{ - UserPrincipalName = $user.UserPrincipalName - DisplayName = $user.DisplayName - AccountEnabled = if ($user.AccountEnabled) { "Yes" } else { "No" } - LastSuccessfulSignIn = $lastSignIn - AssignedLicenses = $SkuId - AssignedLicensesFriendlyName = $friendlyName - Inheritance = $inheritance - AssignmentType = $assignmentType - IsDirect = $isDirect - IsInherited = $isInherited - } - - # Add to the report - $Report += $licenseData - } - } - - - # Calculate metrics for summary boxes - $script:directLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $false }).Count - $script:inheritedLicenses = ($Report | Where-Object { $_.IsInherited -eq $true -and $_.IsDirect -eq $false }).Count - $script:bothLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $true }).Count - $script:DisabledUsersWithLicenses = ($Report | Where-Object { $_.AccountEnabled -eq "No" } | Select-Object -Unique UserPrincipalName).Count - - # Output summary information - Write-Host "INFO: License Summary:" -ForegroundColor Cyan - Write-Host "Total users processed: $totalUsers" -ForegroundColor White - Write-Host "Users with licenses: $($Report | Select-Object -Unique UserPrincipalName | Measure-Object | Select-Object -ExpandProperty Count)" -ForegroundColor White - Write-Host "Direct license assignments: $script:directLicenses" -ForegroundColor White - Write-Host "Inherited license assignments: $script:inheritedLicenses" -ForegroundColor White - Write-Host "Both direct and inherited: $script:bothLicenses" -ForegroundColor White - Write-Host "Disabled users with licenses: $script:DisabledUsersWithLicenses" -ForegroundColor White - - # Export to HTML - New-HTMLReport -Organization $Organization -Report $Report -SubscriptionOverview $SubscriptionOverview -ExportPath $ExportPath - - # Send email with the report - $emailSent = $false - if ($SendEmail) { - $subject = "$Organization - Microsoft 365 License Assignment Report" - $bodyHtml = "

Microsoft 365 License Assignment Report

Attached is the latest Microsoft 365 license assignment report for $Organization.

Open the attached HTML in a browser for the full report.

Generated by RKSolutions - please do not reply.

" - $emailSent = Send-EmailWithAttachment -Recipient $Recipient -AttachmentPath $script:ExportPath -From $From -Subject $subject -BodyHtml $bodyHtml - - if ($emailSent) { - Write-Host "INFO: Email sent successfully." -ForegroundColor Green - } - else { - Write-Host "ERROR: Failed to send email." -ForegroundColor Red - } - } - else { - Write-Host "INFO: Email sending is disabled. Set -SendEmail to $true to enable." -ForegroundColor Yellow - } - - # Clean up the report file - if ($SendEmail -and $emailSent) { - if (Test-Path -Path $script:ExportPath) { - Remove-Item -Path $script:ExportPath -Force - Write-Host "INFO: Temporary report file deleted." -ForegroundColor Green - } else { - Write-Host "INFO: No temporary report file found to delete." -ForegroundColor Yellow - } - } - - # Get Organization Name - $Organization = Invoke-MgGraphRequest -Uri "beta/organization" -OutputType PSObject | Select-Object -Expand Value | Select-Object -ExpandProperty DisplayName - - # Get product identifiers - $Identifiers = Get-LicenseIdentifiers - - # Select all SKUs with friendly display name - [array]$SKU_friendly = $Identifiers | Select-Object GUID, String_Id, Product_Display_Name -Unique - - # NEW CLOUD LICENSING API: Get allotments with subscription details in one call (Beta API) - # This replaces the previous two separate calls to subscribedSkus and directory/subscriptions - Write-Host "INFO: Retrieving allotment and subscription data using Cloud Licensing API..." -ForegroundColor Cyan - - try { - # Try new Cloud Licensing API first - # Note: subscriptions is included by default, no need to expand - [Array]$allotments = Invoke-GraphRequestWithPaging -Uri "beta/admin/cloudLicensing/allotments?`$select=id,allottedUnits,consumedUnits,skuId,skuPartNumber,assignableTo,subscriptions" - - if (-not $allotments -or $allotments.Count -eq 0) { - throw "Allotments API returned empty results" - } - - $useCloudLicensingAPI = $true - Write-Host "INFO: Successfully retrieved data from Cloud Licensing API" -ForegroundColor Green - - # Diagnostic: Show what properties are available in first subscription (if verbose) - if ($VerbosePreference -eq 'Continue' -and $allotments.Count -gt 0) { - $firstAllotment = $allotments[0] - if ($firstAllotment.subscriptions -and $firstAllotment.subscriptions.Count -gt 0) { - $firstSub = $firstAllotment.subscriptions[0] - Write-Verbose "Subscription properties available: $($firstSub.PSObject.Properties.Name -join ', ')" - } - } - - # Always show diagnostic info about subscription structure (helps with troubleshooting) - Write-Host "INFO: Found $($allotments.Count) allotments" -ForegroundColor Cyan - $totalSubscriptions = ($allotments | ForEach-Object { if ($_.subscriptions) { $_.subscriptions.Count } else { 0 } } | Measure-Object -Sum).Sum - Write-Host "INFO: Total subscriptions across all allotments: $totalSubscriptions" -ForegroundColor Cyan - - # Supplementary call to get dates from legacy API if needed - # The allotments API includes startDate and nextLifecycleDate, but we fetch - # from legacy API as a fallback in case subscription IDs don't match perfectly - Write-Host "INFO: Retrieving subscription dates as fallback..." -ForegroundColor Cyan - [Array]$LegacySubscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions?`$select=id,createdDateTime,nextLifecycleDateTime,skuId" -OutputType PSObject | - Select-Object -ExpandProperty Value - - # Create lookup table for quick access to both created and end dates - $subscriptionDateLookup = @{} - foreach ($legacySub in $LegacySubscriptions) { - if ($legacySub.id) { - $subscriptionDateLookup[$legacySub.id] = @{ - CreatedDate = $legacySub.createdDateTime - EndDate = $legacySub.nextLifecycleDateTime - } - } - } - - Write-Host "INFO: Created lookup table with $($subscriptionDateLookup.Count) subscription dates" -ForegroundColor Cyan - if ($VerbosePreference -eq 'Continue' -and $subscriptionDateLookup.Count -gt 0) { - Write-Verbose "Sample lookup IDs: $(($subscriptionDateLookup.Keys | Select-Object -First 3) -join ', ')" - $firstId = $subscriptionDateLookup.Keys | Select-Object -First 1 - if ($firstId) { - Write-Verbose "Sample data for ID $firstId - CreatedDate: $($subscriptionDateLookup[$firstId].CreatedDate), EndDate: $($subscriptionDateLookup[$firstId].EndDate)" - } - } - - # Show how many subscriptions have end dates - $subsWithEndDates = ($LegacySubscriptions | Where-Object { $_.nextLifecycleDateTime }).Count - $subsWithoutEndDates = $LegacySubscriptions.Count - $subsWithEndDates - Write-Host "INFO: Subscriptions with end dates: $subsWithEndDates, without end dates: $subsWithoutEndDates" -ForegroundColor Cyan - } - catch { - # Fallback to legacy API if Cloud Licensing API fails - Write-Host "WARNING: Cloud Licensing API failed, falling back to legacy API. Error: $($_.Exception.Message)" -ForegroundColor Yellow - $useCloudLicensingAPI = $false - - # Legacy API calls - [Array]$Skus = Invoke-MgGraphRequest -Uri "Beta/subscribedSkus" -OutputType PSObject | - Select-Object -ExpandProperty Value - [Array]$Subscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions" -OutputType PSObject | - Select-Object -ExpandProperty Value - } - - # Create an overview of subscriptions with their end date - $SubscriptionOverview = @() - - if ($useCloudLicensingAPI) { - # NEW: Process allotments from Cloud Licensing API - $datesFoundCount = 0 - $datesNotFoundCount = 0 - - # Group allotments by SKU to combine duplicate licenses - $allotmentsBySkuId = @{} - - foreach ($allotment in $allotments) { - # Get friendly name - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $allotment.skuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = if ($allotment.skuPartNumber) { $allotment.skuPartNumber } else { "Unknown License ($($allotment.skuId))" } - } - - # Initialize SKU group if not exists - if (-not $allotmentsBySkuId.ContainsKey($allotment.skuId)) { - $allotmentsBySkuId[$allotment.skuId] = @{ - FriendlyName = $friendlyName - SKUPartNumber = $allotment.skuPartNumber - AssignableTo = $allotment.assignableTo - TotalLicenses = 0 - ConsumedUnits = 0 - CreatedDates = @() - EndDates = @() - SubscriptionIds = @() - } - } - - # Aggregate license counts - $allotmentsBySkuId[$allotment.skuId].TotalLicenses += if ($allotment.allottedUnits) { $allotment.allottedUnits } else { 0 } - $allotmentsBySkuId[$allotment.skuId].ConsumedUnits += if ($allotment.consumedUnits) { $allotment.consumedUnits } else { 0 } - - # Process subscriptions to collect dates - if ($allotment.subscriptions -and $allotment.subscriptions.Count -gt 0) { - foreach ($subscription in $allotment.subscriptions) { - if ($subscription.id) { - $allotmentsBySkuId[$allotment.skuId].SubscriptionIds += $subscription.id - } - - # Resolve created/start date - $subCreated = $null - if ($subscription.startDate) { $subCreated = $subscription.startDate } - elseif ($subscription.createdDateTime) { $subCreated = $subscription.createdDateTime } - elseif ($subscription.createdDate) { $subCreated = $subscription.createdDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subCreated = $subscriptionDateLookup[$subscription.id].CreatedDate - } - - if ($subCreated) { - $datesFoundCount++ - $d = try { [DateTime]$subCreated } catch { $null } - if ($d) { - $allotmentsBySkuId[$allotment.skuId].CreatedDates += $d - } - } else { - $datesNotFoundCount++ - } - - # Resolve end/lifecycle date - $subEnd = $null - if ($subscription.nextLifecycleDate) { $subEnd = $subscription.nextLifecycleDate } - elseif ($subscription.nextLifecycleDateTime) { $subEnd = $subscription.nextLifecycleDateTime } - elseif ($subscription.endDate) { $subEnd = $subscription.endDate } - elseif ($subscription.expiryDate) { $subEnd = $subscription.expiryDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subEnd = $subscriptionDateLookup[$subscription.id].EndDate - } - - if ($subEnd -and $subEnd -ne "No end date found") { - $e = try { [DateTime]$subEnd } catch { $null } - if ($e) { - $allotmentsBySkuId[$allotment.skuId].EndDates += $e - } - } - } - } - } - - # Now create subscription overview with one row per SKU - foreach ($skuId in $allotmentsBySkuId.Keys) { - $skuData = $allotmentsBySkuId[$skuId] - - # Get earliest created date - $createdDate = if ($skuData.CreatedDates.Count -gt 0) { - ($skuData.CreatedDates | Measure-Object -Minimum).Minimum - } else { $null } - - $formattedCreatedDate = if ($createdDate) { - try { Get-Date $createdDate -Format "dd-MM-yyyy HH:mm" } - catch { $createdDate.ToString() } - } else { "Unknown" } - - # Get latest end date - $endDate = if ($skuData.EndDates.Count -gt 0) { - ($skuData.EndDates | Measure-Object -Maximum).Maximum - } else { $null } - - if (-not $endDate) { - $endDate = "No end date found" - } - - $formattedEndDate = if ($endDate -ne "No end date found") { - try { Get-Date $endDate -Format "dd-MM-yyyy HH:mm" } - catch { $endDate } - } else { $endDate } - - # Determine license status - $licenseStatus = "Enabled" - if ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - $licenseStatus = if ($dateObj -gt (Get-Date)) { "Enabled" } else { "Disabled" } - } catch { - $licenseStatus = "Unknown" - } - } - - $availableLicenses = $skuData.TotalLicenses - $skuData.ConsumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = ($skuData.SubscriptionIds | Select-Object -First 1) - FriendlyName = $skuData.FriendlyName - SKUPartNumber = $skuData.SKUPartNumber - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $skuData.ConsumedUnits - TotalLicenses = $skuData.TotalLicenses - AvailableLicenses = $availableLicenses - AssignableTo = $skuData.AssignableTo - } - } - - # Show summary of date matching - Write-Host "INFO: Created dates - Found: $datesFoundCount, Not Found: $datesNotFoundCount" -ForegroundColor Cyan - } - else { - # LEGACY: Process subscriptions from old API - foreach ($subscription in $Subscriptions) { - $sku = $Skus | Where-Object { $_.SkuId -eq $subscription.SkuId } - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $sku.SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($($sku.SkuId))" - } - - $endDate = if ($null -eq $subscription.NextLifecycleDateTime) { - "No end date found" - } else { - $subscription.NextLifecycleDateTime - } - - # Format dates - $formattedCreatedDate = if ($subscription.CreatedDateTime -is [DateTime]) { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } elseif ($subscription.CreatedDateTime) { - try { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } catch { - $subscription.CreatedDateTime - } - } else { - "Unknown" - } - - $formattedEndDate = if ($endDate -is [DateTime]) { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } elseif ($endDate -and $endDate -ne "No end date found") { - try { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } catch { - $endDate - } - } else { - $endDate - } - - # Determine license status - $licenseStatus = if ($endDate -eq "No end date found") { - "Enabled" - } elseif ($endDate -is [DateTime] -and $endDate -gt (Get-Date)) { - "Enabled" - } elseif ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - if ($dateObj -gt (Get-Date)) { - "Enabled" - } else { - "Disabled" - } - } catch { - "Unknown" - } - } else { - "Unknown" - } - - # Calculate available licenses - $totalLicenses = if ($subscription.TotalLicenses) { $subscription.TotalLicenses } else { 0 } - $consumedUnits = if ($sku.ConsumedUnits) { $sku.ConsumedUnits } else { 0 } - $availableLicenses = $totalLicenses - $consumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = $subscription.Id - FriendlyName = $friendlyName - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $consumedUnits - TotalLicenses = $totalLicenses - AvailableLicenses = $availableLicenses - } - } - } - - # Output the overview - Write-Host "INFO: Generating subscription overview..." -ForegroundColor Cyan - - # Get all users with licenses - using paging to ensure all results are retrieved - Write-Host "INFO: Retrieving user license data..." -ForegroundColor Cyan - $users = Invoke-GraphRequestWithPaging -Uri "beta/users?`$select=UserPrincipalName,LicenseAssignmentStates,DisplayName,AccountEnabled,AssignedLicenses,signInActivity&`$top=999" - - # Get all groups with their licenses - Write-Host "INFO: Retrieving group license data..." -ForegroundColor Cyan - $Groups = Invoke-GraphRequestWithPaging -Uri "beta/groups?`$select=id,displayName,assignedLicenses&`$top=999" - $groupsWithLicenses = @() - - # Loop through each group and check if it has any licenses assigned - Write-Host "INFO: Checking groups for licenses..." -ForegroundColor Cyan - foreach ($group in $Groups) { - if ($group.assignedLicenses -and $group.assignedLicenses.Count -gt 0) { - $groupData = [PSCustomObject]@{ - ObjectId = $group.id - DisplayName = $group.displayName - Licenses = $group.assignedLicenses - } - $groupsWithLicenses += $groupData - } - } - - # Initialize the report array - $Report = @() - - # Process user license data - $totalUsers = $users.Count - $currentIndex = 0 - - foreach ($user in $users) { - $currentIndex++ - Write-Progress -Activity "Processing users" -Status "Processing $currentIndex of $totalUsers" -PercentComplete (($currentIndex / $totalUsers) * 100) - - # Skip users with no license assignment states - if (-not $user.LicenseAssignmentStates) { - continue - } - - # Group licenses by SkuId to detect both direct and inherited assignments - $licensesBySkuId = @{} - - foreach ($license in $user.LicenseAssignmentStates) { - $SkuId = $license.SkuId - $AssignedByGroup = $license.AssignedByGroup - - if (-not $licensesBySkuId.ContainsKey($SkuId)) { - $licensesBySkuId[$SkuId] = @{ - DirectAssignment = $false - GroupAssignments = @() - } - } - - if ($null -eq $AssignedByGroup) { - $licensesBySkuId[$SkuId].DirectAssignment = $true - } - else { - $licensesBySkuId[$SkuId].GroupAssignments += $AssignedByGroup - } - } - - # Process each unique license - foreach ($SkuId in $licensesBySkuId.Keys) { - $licenseInfo = $licensesBySkuId[$SkuId] - $isDirect = $licenseInfo.DirectAssignment - $isInherited = ($licenseInfo.GroupAssignments.Count -gt 0) - - # Determine assignment type - $assignmentType = if ($isDirect -and $isInherited) { - "Both" - } - elseif ($isDirect) { - "Direct" - } - elseif ($isInherited) { - "Inherited" - } - else { - "Unknown" - } - - # Get friendly name for the license - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($SkuId)" - } - - # Get group names if inherited - $groupNames = "" - if ($isInherited) { - $groupNamesList = @() - foreach ($groupId in $licenseInfo.GroupAssignments) { - $group = $groupsWithLicenses | Where-Object { $_.ObjectId -eq $groupId } - if ($group) { - $groupNamesList += $group.DisplayName - } - else { - $groupNamesList += "Unknown Group ($groupId)" - } - } - $groupNames = $groupNamesList -join ", " - } - - # Determine inheritance description - if ($isDirect -and -not $groupNames) { - $inheritance = "Direct" - } - elseif (-not $isDirect -and $groupNames) { - $inheritance = $groupNames - } - elseif ($isDirect -and $groupNames) { - $inheritance = "Direct, $groupNames" - } - else { - $inheritance = "Unknown" - } - - # Last Login Activity (robust handling of null/invalid values) - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSignInDateTime - if ($lastSignIn -eq "No sign-in activity" -or $lastSignIn -eq "Invalid date value") { - $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSuccessfulSignInDateTime - } - - # Create the license data object - $licenseData = [PSCustomObject]@{ - UserPrincipalName = $user.UserPrincipalName - DisplayName = $user.DisplayName - AccountEnabled = if ($user.AccountEnabled) { "Yes" } else { "No" } - LastSuccessfulSignIn = $lastSignIn - AssignedLicenses = $SkuId - AssignedLicensesFriendlyName = $friendlyName - Inheritance = $inheritance - AssignmentType = $assignmentType - IsDirect = $isDirect - IsInherited = $isInherited - } - - # Add to the report - $Report += $licenseData - } - } - - - # Calculate metrics for summary boxes - $script:directLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $false }).Count - $script:inheritedLicenses = ($Report | Where-Object { $_.IsInherited -eq $true -and $_.IsDirect -eq $false }).Count - $script:bothLicenses = ($Report | Where-Object { $_.IsDirect -eq $true -and $_.IsInherited -eq $true }).Count - $script:DisabledUsersWithLicenses = ($Report | Where-Object { $_.AccountEnabled -eq "No" } | Select-Object -Unique UserPrincipalName).Count - - # Output summary information - Write-Host "INFO: License Summary:" -ForegroundColor Cyan - Write-Host "Total users processed: $totalUsers" -ForegroundColor White - Write-Host "Users with licenses: $($Report | Select-Object -Unique UserPrincipalName | Measure-Object | Select-Object -ExpandProperty Count)" -ForegroundColor White - Write-Host "Direct license assignments: $script:directLicenses" -ForegroundColor White - Write-Host "Inherited license assignments: $script:inheritedLicenses" -ForegroundColor White - Write-Host "Both direct and inherited: $script:bothLicenses" -ForegroundColor White - Write-Host "Disabled users with licenses: $script:DisabledUsersWithLicenses" -ForegroundColor White - - # Export to HTML - New-HTMLReport -Organization $Organization -Report $Report -SubscriptionOverview $SubscriptionOverview -ExportPath $ExportPath - - # Send email with the report - $emailSent = $false - if ($SendEmail) { - $subject = "$Organization - Microsoft 365 License Assignment Report" - $bodyHtml = "

Microsoft 365 License Assignment Report

Attached is the latest Microsoft 365 license assignment report for $Organization.

Open the attached HTML in a browser for the full report.

Generated by RKSolutions - please do not reply.

" - $emailSent = Send-EmailWithAttachment -Recipient $Recipient -AttachmentPath $script:ExportPath -From $From -Subject $subject -BodyHtml $bodyHtml - - if ($emailSent) { - Write-Host "INFO: Email sent successfully." -ForegroundColor Green - } - else { - Write-Host "ERROR: Failed to send email." -ForegroundColor Red - } - } - else { - Write-Host "INFO: Email sending is disabled. Set -SendEmail to $true to enable." -ForegroundColor Yellow - } - - # Clean up the report file - if ($SendEmail -and $emailSent) { - if (Test-Path -Path $script:ExportPath) { - Remove-Item -Path $script:ExportPath -Force - Write-Host "INFO: Temporary report file deleted." -ForegroundColor Green - } else { - Write-Host "INFO: No temporary report file found to delete." -ForegroundColor Yellow - } - } - - # Get Organization Name - $Organization = Invoke-MgGraphRequest -Uri "beta/organization" -OutputType PSObject | Select-Object -Expand Value | Select-Object -ExpandProperty DisplayName - - # Get product identifiers - $Identifiers = Get-LicenseIdentifiers - - # Select all SKUs with friendly display name - [array]$SKU_friendly = $Identifiers | Select-Object GUID, String_Id, Product_Display_Name -Unique - - # NEW CLOUD LICENSING API: Get allotments with subscription details in one call (Beta API) - # This replaces the previous two separate calls to subscribedSkus and directory/subscriptions - Write-Host "INFO: Retrieving allotment and subscription data using Cloud Licensing API..." -ForegroundColor Cyan - - try { - # Try new Cloud Licensing API first - # Note: subscriptions is included by default, no need to expand - [Array]$allotments = Invoke-GraphRequestWithPaging -Uri "beta/admin/cloudLicensing/allotments?`$select=id,allottedUnits,consumedUnits,skuId,skuPartNumber,assignableTo,subscriptions" - - if (-not $allotments -or $allotments.Count -eq 0) { - throw "Allotments API returned empty results" - } - - $useCloudLicensingAPI = $true - Write-Host "INFO: Successfully retrieved data from Cloud Licensing API" -ForegroundColor Green - - # Diagnostic: Show what properties are available in first subscription (if verbose) - if ($VerbosePreference -eq 'Continue' -and $allotments.Count -gt 0) { - $firstAllotment = $allotments[0] - if ($firstAllotment.subscriptions -and $firstAllotment.subscriptions.Count -gt 0) { - $firstSub = $firstAllotment.subscriptions[0] - Write-Verbose "Subscription properties available: $($firstSub.PSObject.Properties.Name -join ', ')" - } - } - - # Always show diagnostic info about subscription structure (helps with troubleshooting) - Write-Host "INFO: Found $($allotments.Count) allotments" -ForegroundColor Cyan - $totalSubscriptions = ($allotments | ForEach-Object { if ($_.subscriptions) { $_.subscriptions.Count } else { 0 } } | Measure-Object -Sum).Sum - Write-Host "INFO: Total subscriptions across all allotments: $totalSubscriptions" -ForegroundColor Cyan - - # Supplementary call to get dates from legacy API if needed - # The allotments API includes startDate and nextLifecycleDate, but we fetch - # from legacy API as a fallback in case subscription IDs don't match perfectly - Write-Host "INFO: Retrieving subscription dates as fallback..." -ForegroundColor Cyan - [Array]$LegacySubscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions?`$select=id,createdDateTime,nextLifecycleDateTime,skuId" -OutputType PSObject | - Select-Object -ExpandProperty Value - - # Create lookup table for quick access to both created and end dates - $subscriptionDateLookup = @{} - foreach ($legacySub in $LegacySubscriptions) { - if ($legacySub.id) { - $subscriptionDateLookup[$legacySub.id] = @{ - CreatedDate = $legacySub.createdDateTime - EndDate = $legacySub.nextLifecycleDateTime - } - } - } - - Write-Host "INFO: Created lookup table with $($subscriptionDateLookup.Count) subscription dates" -ForegroundColor Cyan - if ($VerbosePreference -eq 'Continue' -and $subscriptionDateLookup.Count -gt 0) { - Write-Verbose "Sample lookup IDs: $(($subscriptionDateLookup.Keys | Select-Object -First 3) -join ', ')" - $firstId = $subscriptionDateLookup.Keys | Select-Object -First 1 - if ($firstId) { - Write-Verbose "Sample data for ID $firstId - CreatedDate: $($subscriptionDateLookup[$firstId].CreatedDate), EndDate: $($subscriptionDateLookup[$firstId].EndDate)" - } - } - - # Show how many subscriptions have end dates - $subsWithEndDates = ($LegacySubscriptions | Where-Object { $_.nextLifecycleDateTime }).Count - $subsWithoutEndDates = $LegacySubscriptions.Count - $subsWithEndDates - Write-Host "INFO: Subscriptions with end dates: $subsWithEndDates, without end dates: $subsWithoutEndDates" -ForegroundColor Cyan - } - catch { - # Fallback to legacy API if Cloud Licensing API fails - Write-Host "WARNING: Cloud Licensing API failed, falling back to legacy API. Error: $($_.Exception.Message)" -ForegroundColor Yellow - $useCloudLicensingAPI = $false - - # Legacy API calls - [Array]$Skus = Invoke-MgGraphRequest -Uri "Beta/subscribedSkus" -OutputType PSObject | - Select-Object -ExpandProperty Value - [Array]$Subscriptions = Invoke-MgGraphRequest -Uri "beta/directory/subscriptions" -OutputType PSObject | - Select-Object -ExpandProperty Value - } - - # Create an overview of subscriptions with their end date - $SubscriptionOverview = @() - - if ($useCloudLicensingAPI) { - # NEW: Process allotments from Cloud Licensing API - $datesFoundCount = 0 - $datesNotFoundCount = 0 - - # Group allotments by SKU to combine duplicate licenses - $allotmentsBySkuId = @{} - - foreach ($allotment in $allotments) { - # Get friendly name - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $allotment.skuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = if ($allotment.skuPartNumber) { $allotment.skuPartNumber } else { "Unknown License ($($allotment.skuId))" } - } - - # Initialize SKU group if not exists - if (-not $allotmentsBySkuId.ContainsKey($allotment.skuId)) { - $allotmentsBySkuId[$allotment.skuId] = @{ - FriendlyName = $friendlyName - SKUPartNumber = $allotment.skuPartNumber - AssignableTo = $allotment.assignableTo - TotalLicenses = 0 - ConsumedUnits = 0 - CreatedDates = @() - EndDates = @() - SubscriptionIds = @() - } - } - - # Aggregate license counts - $allotmentsBySkuId[$allotment.skuId].TotalLicenses += if ($allotment.allottedUnits) { $allotment.allottedUnits } else { 0 } - $allotmentsBySkuId[$allotment.skuId].ConsumedUnits += if ($allotment.consumedUnits) { $allotment.consumedUnits } else { 0 } - - # Process subscriptions to collect dates - if ($allotment.subscriptions -and $allotment.subscriptions.Count -gt 0) { - foreach ($subscription in $allotment.subscriptions) { - if ($subscription.id) { - $allotmentsBySkuId[$allotment.skuId].SubscriptionIds += $subscription.id - } - - # Resolve created/start date - $subCreated = $null - if ($subscription.startDate) { $subCreated = $subscription.startDate } - elseif ($subscription.createdDateTime) { $subCreated = $subscription.createdDateTime } - elseif ($subscription.createdDate) { $subCreated = $subscription.createdDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subCreated = $subscriptionDateLookup[$subscription.id].CreatedDate - } - - if ($subCreated) { - $datesFoundCount++ - $d = try { [DateTime]$subCreated } catch { $null } - if ($d) { - $allotmentsBySkuId[$allotment.skuId].CreatedDates += $d - } - } else { - $datesNotFoundCount++ - } - - # Resolve end/lifecycle date - $subEnd = $null - if ($subscription.nextLifecycleDate) { $subEnd = $subscription.nextLifecycleDate } - elseif ($subscription.nextLifecycleDateTime) { $subEnd = $subscription.nextLifecycleDateTime } - elseif ($subscription.endDate) { $subEnd = $subscription.endDate } - elseif ($subscription.expiryDate) { $subEnd = $subscription.expiryDate } - elseif ($subscriptionDateLookup -and $subscription.id -and $subscriptionDateLookup.ContainsKey($subscription.id)) { - $subEnd = $subscriptionDateLookup[$subscription.id].EndDate - } - - if ($subEnd -and $subEnd -ne "No end date found") { - $e = try { [DateTime]$subEnd } catch { $null } - if ($e) { - $allotmentsBySkuId[$allotment.skuId].EndDates += $e - } - } - } - } - } - - # Now create subscription overview with one row per SKU - foreach ($skuId in $allotmentsBySkuId.Keys) { - $skuData = $allotmentsBySkuId[$skuId] - - # Get earliest created date - $createdDate = if ($skuData.CreatedDates.Count -gt 0) { - ($skuData.CreatedDates | Measure-Object -Minimum).Minimum - } else { $null } - - $formattedCreatedDate = if ($createdDate) { - try { Get-Date $createdDate -Format "dd-MM-yyyy HH:mm" } - catch { $createdDate.ToString() } - } else { "Unknown" } - - # Get latest end date - $endDate = if ($skuData.EndDates.Count -gt 0) { - ($skuData.EndDates | Measure-Object -Maximum).Maximum - } else { $null } - - if (-not $endDate) { - $endDate = "No end date found" - } - - $formattedEndDate = if ($endDate -ne "No end date found") { - try { Get-Date $endDate -Format "dd-MM-yyyy HH:mm" } - catch { $endDate } - } else { $endDate } - - # Determine license status - $licenseStatus = "Enabled" - if ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - $licenseStatus = if ($dateObj -gt (Get-Date)) { "Enabled" } else { "Disabled" } - } catch { - $licenseStatus = "Unknown" - } - } - - $availableLicenses = $skuData.TotalLicenses - $skuData.ConsumedUnits - - $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = ($skuData.SubscriptionIds | Select-Object -First 1) - FriendlyName = $skuData.FriendlyName - SKUPartNumber = $skuData.SKUPartNumber - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $skuData.ConsumedUnits - TotalLicenses = $skuData.TotalLicenses - AvailableLicenses = $availableLicenses - AssignableTo = $skuData.AssignableTo - } - } - - # Show summary of date matching - Write-Host "INFO: Created dates - Found: $datesFoundCount, Not Found: $datesNotFoundCount" -ForegroundColor Cyan - } - else { - # LEGACY: Process subscriptions from old API - foreach ($subscription in $Subscriptions) { - $sku = $Skus | Where-Object { $_.SkuId -eq $subscription.SkuId } - $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $sku.SkuId } | - Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - - if (-not $friendlyName) { - $friendlyName = "Unknown License ($($sku.SkuId))" - } - - $endDate = if ($null -eq $subscription.NextLifecycleDateTime) { - "No end date found" - } else { - $subscription.NextLifecycleDateTime - } - - # Format dates - $formattedCreatedDate = if ($subscription.CreatedDateTime -is [DateTime]) { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } elseif ($subscription.CreatedDateTime) { - try { - Get-Date $subscription.CreatedDateTime -Format "dd-MM-yyyy HH:mm" - } catch { - $subscription.CreatedDateTime - } - } else { - "Unknown" - } - - $formattedEndDate = if ($endDate -is [DateTime]) { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } elseif ($endDate -and $endDate -ne "No end date found") { - try { - Get-Date $endDate -Format "dd-MM-yyyy HH:mm" - } catch { - $endDate - } - } else { - $endDate - } - - # Determine license status - $licenseStatus = if ($endDate -eq "No end date found") { - "Enabled" - } elseif ($endDate -is [DateTime] -and $endDate -gt (Get-Date)) { - "Enabled" - } elseif ($endDate -ne "No end date found") { - try { - $dateObj = [DateTime]$endDate - if ($dateObj -gt (Get-Date)) { - "Enabled" - } else { - "Disabled" - } - } catch { - "Unknown" - } - } else { - "Unknown" - } + +
+
+ Filters: + + + + +
+
+
+ License Assignment +
+ + Show all +
+
+
+ + + + + + + + + + + + + + $tableRows + +
Display NameUser Principal NameAccount StatusLast Successful Sign InLicenseAssignment TypeInheritance Details
+
+
+
- # Calculate available licenses - $totalLicenses = if ($subscription.TotalLicenses) { $subscription.TotalLicenses } else { 0 } - $consumedUnits = if ($sku.ConsumedUnits) { $sku.ConsumedUnits } else { 0 } - $availableLicenses = $totalLicenses - $consumedUnits + +
+
+
+ Subscription Overview +
+ + Show all +
+
+
+ + + + + + + + + + + + + + $subscriptionRows + +
SubscriptionCreated DateEnd DateLicense StatusConsumed UnitsTotal LicensesAvailable Licenses
+
+
+
- $SubscriptionOverview += [PSCustomObject]@{ - SubscriptionId = $subscription.Id - FriendlyName = $friendlyName - CreatedDate = $formattedCreatedDate - EndDate = $formattedEndDate - LicenseStatus = $licenseStatus - ConsumedUnits = $consumedUnits - TotalLicenses = $totalLicenses - AvailableLicenses = $availableLicenses + +
+
+
+ Disabled Users with Licenses +
+ + Show all +
+
+
+ + + + + + + + + + + + + + $disabledUsersRows + +
Display NameUser Principal NameAccount StatusLast Successful Sign InLicenseAssignment TypeInheritance Details
+
+
+
+ + +"@ - # Export to HTML - New-HTMLReport -Organization $Organization -Report $Report -SubscriptionOverview $SubscriptionOverview -ExportPath $ExportPath + # Report-specific CSS + $customCss = @" + .rk-filter-bar .form-select { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 6px; + } + .table-danger td { + background-color: rgba(192, 57, 43, 0.08) !important; + } + [data-theme="dark"] .table-danger td { + background-color: rgba(224, 96, 80, 0.1) !important; + } +"@ - # Send email with the report - $emailSent = $false - if ($SendEmail) { - $subject = "$Organization - Microsoft 365 License Assignment Report" - $bodyHtml = "

Microsoft 365 License Assignment Report

Attached is the latest Microsoft 365 license assignment report for $Organization.

Open the attached HTML in a browser for the full report.

Generated by RKSolutions - please do not reply.

" - $emailSent = Send-EmailWithAttachment -Recipient $Recipient -AttachmentPath $script:ExportPath -From $From -Subject $subject -BodyHtml $bodyHtml + # Generate the full HTML report using the shared template + $htmlContent = New-RKSolutionsReportTemplate ` + -TenantName $Organization ` + -ReportTitle 'License' ` + -ReportSlug 'm365-licenses' ` + -Eyebrow 'M365 LICENSE ASSIGNMENT' ` + -Lede 'License assignment overview including direct, inherited, and disabled user assignments.' ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml ` + -CustomCss $customCss ` + -ReportDate $CurrentDate ` + -Tags @('M365', 'Licensing', 'Entra ID') - if ($emailSent) { - Write-Host "INFO: Email sent successfully." -ForegroundColor Green - } - else { - Write-Host "ERROR: Failed to send email." -ForegroundColor Red - } + # Export to HTML file + $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 + + # Set script-scoped variable for email attachment + $script:ExportPath = $ExportPath + + Write-Host "All actions completed successfully." -ForegroundColor Cyan + Write-Host "Report saved to: $ExportPath" -ForegroundColor Cyan + + # Open the HTML file (cross-platform: Invoke-Item uses default handler; fallback so script does not fail in headless env) + if (-not $SendEmail) { + try { + Invoke-Item $ExportPath -ErrorAction Stop + } catch { + Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow } - else { - Write-Host "INFO: Email sending is disabled. Set -SendEmail to $true to enable." -ForegroundColor Yellow + } +} + +function Get-LicenseIdentifiers { + $header = 'Product_Display_Name', 'String_Id', 'GUID', 'Service_Plan_Name', 'Service_Plan_Id', 'Service_Plans_Included_Friendly_Names' + $params = @{ + Method = 'Get' + Uri = "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv" + } + $Identifiers = Invoke-RestMethod @params | ConvertFrom-Csv -Header $header | + ForEach-Object { + [PSCustomObject]@{ + GUID = $_.GUID + String_Id = $_.String_Id + Product_Display_Name = $_.Product_Display_Name } + } + return $Identifiers | Select-Object -Skip 1 +} - # Clean up the report file - if ($SendEmail -and $emailSent) { - if (Test-Path -Path $script:ExportPath) { - Remove-Item -Path $script:ExportPath -Force - Write-Host "INFO: Temporary report file deleted." -ForegroundColor Green - } else { - Write-Host "INFO: No temporary report file found to delete." -ForegroundColor Yellow - } - }subject -BodyHtml # CODE + +function Invoke-M365LicenseReportCore { + param( + [Parameter(Mandatory=$false)] [switch] $SendEmail, + [Parameter(Mandatory=$false)] [string[]] $Recipient, + [Parameter(Mandatory=$false)] [string] $From, + [Parameter(Mandatory=$false)] [string] $ExportPath + ) + # CODE # Get Organization Name $Organization = Invoke-MgGraphRequest -Uri "beta/organization" -OutputType PSObject | Select-Object -Expand Value | Select-Object -ExpandProperty DisplayName @@ -3986,26 +774,26 @@ function Invoke-M365LicenseReportCore { foreach ($user in $users) { $currentIndex++ Write-Progress -Activity "Processing users" -Status "Processing $currentIndex of $totalUsers" -PercentComplete (($currentIndex / $totalUsers) * 100) - + # Skip users with no license assignment states if (-not $user.LicenseAssignmentStates) { continue } - + # Group licenses by SkuId to detect both direct and inherited assignments $licensesBySkuId = @{} - + foreach ($license in $user.LicenseAssignmentStates) { $SkuId = $license.SkuId $AssignedByGroup = $license.AssignedByGroup - + if (-not $licensesBySkuId.ContainsKey($SkuId)) { $licensesBySkuId[$SkuId] = @{ DirectAssignment = $false GroupAssignments = @() } } - + if ($null -eq $AssignedByGroup) { $licensesBySkuId[$SkuId].DirectAssignment = $true } @@ -4013,13 +801,13 @@ function Invoke-M365LicenseReportCore { $licensesBySkuId[$SkuId].GroupAssignments += $AssignedByGroup } } - + # Process each unique license foreach ($SkuId in $licensesBySkuId.Keys) { $licenseInfo = $licensesBySkuId[$SkuId] $isDirect = $licenseInfo.DirectAssignment $isInherited = ($licenseInfo.GroupAssignments.Count -gt 0) - + # Determine assignment type $assignmentType = if ($isDirect -and $isInherited) { "Both" @@ -4033,15 +821,15 @@ function Invoke-M365LicenseReportCore { else { "Unknown" } - + # Get friendly name for the license $friendlyName = $SKU_friendly | Where-Object { $_.GUID -eq $SkuId } | Select-Object -ExpandProperty Product_Display_Name -ErrorAction SilentlyContinue - + if (-not $friendlyName) { $friendlyName = "Unknown License ($SkuId)" } - + # Get group names if inherited $groupNames = "" if ($isInherited) { @@ -4057,7 +845,7 @@ function Invoke-M365LicenseReportCore { } $groupNames = $groupNamesList -join ", " } - + # Determine inheritance description if ($isDirect -and -not $groupNames) { $inheritance = "Direct" @@ -4077,7 +865,7 @@ function Invoke-M365LicenseReportCore { if ($lastSignIn -eq "No sign-in activity" -or $lastSignIn -eq "Invalid date value") { $lastSignIn = ConvertTo-DateString -Value $user.signInActivity.lastSuccessfulSignInDateTime } - + # Create the license data object $licenseData = [PSCustomObject]@{ UserPrincipalName = $user.UserPrincipalName @@ -4091,7 +879,7 @@ function Invoke-M365LicenseReportCore { IsDirect = $isDirect IsInherited = $isInherited } - + # Add to the report $Report += $licenseData } @@ -4131,28 +919,7 @@ function Invoke-M365LicenseReportCore { } } else { - Write-Host "INFO: Email sending is disabled. Set -SendEmail to $true to enable." -ForegroundColor Yellow - } - - # Clean up the report file - if ($SendEmail -and $emailSent) { - if (Test-Path -Path $script:ExportPath) { - Remove-Item -Path $script:ExportPath -Force - Write-Host "INFO: Temporary report file deleted." -ForegroundColor Green - } else { - Write-Host "INFO: No temporary report file found to delete." -ForegroundColor Yellow - } - }body - - if ($emailSent) { - Write-Host "INFO: Email sent successfully." -ForegroundColor Green - } - else { - Write-Host "ERROR: Failed to send email." -ForegroundColor Red - } - } - else { - Write-Host "INFO: Email sending is disabled. Set -SendEmail to $true to enable." -ForegroundColor Yellow + Write-Host "INFO: Email sending is disabled. Set -SendEmail to `$true to enable." -ForegroundColor Yellow } # Clean up the report file From 41b413f2030025dc93f4669589c74ba98ccf409e Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 22:13:53 +0200 Subject: [PATCH 06/11] refactor: migrate Intune Enrollment Flows reports to shared brand template Both New-AssignmentOverviewHtmlReport and New-DeviceVisualizationHtmlReport now use New-RKSolutionsReportTemplate. Flow diagram and architecture CSS preserved as report-specific CustomCss. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/IntuneEnrollmentFlows.ps1 | 1000 ++++++++-------------- 1 file changed, 376 insertions(+), 624 deletions(-) diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index b8936d8..007d711 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -1675,317 +1675,145 @@ __PH_FILTERS__ function New-AssignmentOverviewHtmlReport { param([Parameter(Mandatory)][array]$PolicyAssignments, [string]$TenantName = "Intune Tenant", [Parameter(Mandatory)][string]$OutputPath) $fragment = Get-AssignmentOverviewTabFragment -PolicyAssignments $PolicyAssignments -TenantName $TenantName - # Script in literal here-string so $ in JavaScript are not expanded by PowerShell (same approach as device report). - $assignmentOverviewScript = @' -document.addEventListener('DOMContentLoaded', function() { - var themeToggle = document.getElementById('themeToggle'); - var prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); - function applyTheme(isDark) { - document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); - if (themeToggle) themeToggle.checked = isDark; - } - var saved = localStorage.getItem('theme'); - if (saved === 'dark' || saved === 'light') { - applyTheme(saved === 'dark'); - } else { - applyTheme(prefersDark.matches); - } - if (themeToggle) themeToggle.addEventListener('change', function() { - var isDark = this.checked; - document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); - localStorage.setItem('theme', isDark ? 'dark' : 'light'); - }); - prefersDark.addEventListener('change', function(e) { - if (localStorage.getItem('theme') === null) { - applyTheme(e.matches); - } - }); - if (jQuery && jQuery.fn.DataTable && jQuery('#allAssignmentsTable').length > 0) { - var $allTbl = jQuery('#allAssignmentsTable'); - var preCategories = [], preTargets = [], preFilters = []; - function normFilter(s) { return String(s||'').replace(/\s*\((?:Include|Exclude)$/i, '').trim() || String(s||''); } - $allTbl.find('tbody tr').each(function() { - var $row = jQuery(this); - var $cells = $row.find('td'); - if ($cells.length >= 4) { - var c = jQuery.trim($cells.eq(1).text()); if (c && preCategories.indexOf(c) === -1) preCategories.push(c); - var t = jQuery.trim($cells.eq(2).text()); if (t && preTargets.indexOf(t) === -1) preTargets.push(t); - var f = normFilter(jQuery.trim($cells.eq(3).text())); if (f && preFilters.indexOf(f) === -1) preFilters.push(f); - } - }); - preCategories.sort(); preTargets.sort(); preFilters.sort(); - var at = $allTbl.DataTable({ - responsive: true, pageLength: 25, order: [[1,'asc'],[0,'asc']], dom: 'Bfrtip', buttons: ['copy','csv','excel','pdf','print'], - columnDefs: [ - { targets: [0,1,2,3], className: 'text-start' }, - { targets: 0, width: '25%' }, { targets: 1, width: '15%' }, { targets: 2, width: '15%' }, { targets: 3, width: '20%' } - ], - initComplete: function() { - var api = this.api(); - function escOv(v){ return (v||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - function getOvChecked(menuId){ return jQuery('#'+menuId+' input.filter-cb:checked').map(function(){ return jQuery(this).attr('data-value'); }).get(); } - function updateOvBtn(btnId, menuId, label){ var n = getOvChecked(menuId).length; jQuery('#'+btnId).text(n ? label + ' (' + n + ')' : 'Select...'); } - function normalizeFilterName(v) { var s = String(v||''); return s.replace(/\s*\((?:Include|Exclude)\)$/i, '').trim() || s; } - function fillOvDropdownFromArray(menuId, btnId, values, label) { - var menu = jQuery('#'+menuId); if (!menu.length) return; menu.empty(); - jQuery.each(values, function(i,v){ menu.append(''); }); - menu.find('input.filter-cb').on('change', function(e){ e.stopPropagation(); updateOvBtn(btnId, menuId, label); api.draw(); }); - menu.on('click', function(e){ e.stopPropagation(); }); - updateOvBtn(btnId, menuId, label); - } - fillOvDropdownFromArray('overviewFilterCategoryMenu','overviewFilterCategoryBtn', preCategories, 'Category'); - fillOvDropdownFromArray('overviewFilterTargetMenu','overviewFilterTargetBtn', preTargets, 'Target'); - fillOvDropdownFromArray('overviewFilterFilterMenu','overviewFilterFilterBtn', preFilters, 'Filter'); - var overviewSearchFn = function(settings, data, dataIndex) { - if (settings.nTable && settings.nTable.id !== 'allAssignmentsTable') return true; - if (jQuery('#overviewFilterHideNotAssigned').length && jQuery('#overviewFilterHideNotAssigned').val() === 'hide' && data[2] === 'Not Assigned') return false; - var searchStr = ''; try { var searchApi = new jQuery.fn.dataTable.Api(settings); searchStr = (searchApi.search() || '').trim(); } catch (e) {} - if (searchStr) { var found = false; for (var i = 0; i < data.length; i++) { if (data[i] && data[i].toString().toLowerCase().indexOf(searchStr.toLowerCase()) !== -1) { found = true; break; } } if (!found) return false; } - var c = getOvChecked('overviewFilterCategoryMenu'); if (c.length && jQuery.inArray(data[1], c) === -1) return false; - var t = getOvChecked('overviewFilterTargetMenu'); if (t.length && jQuery.inArray(data[2], t) === -1) return false; - var rowFilterNorm = normalizeFilterName(data[3]); var f = getOvChecked('overviewFilterFilterMenu'); if (f.length && jQuery.inArray(rowFilterNorm, f) === -1) return false; - return true; - }; - jQuery.fn.dataTable.ext.search.push(overviewSearchFn); - var hideNotAssigned = jQuery('#overviewFilterHideNotAssigned'); - if (hideNotAssigned.length) hideNotAssigned.on('change', function(){ api.draw(); }); - var resetBtn = jQuery('#overviewFiltersReset'); - if (resetBtn.length) resetBtn.on('click', function(){ - jQuery('#overviewFilterCategoryMenu,#overviewFilterTargetMenu,#overviewFilterFilterMenu').find('input.filter-cb').prop('checked', false); - updateOvBtn('overviewFilterCategoryBtn','overviewFilterCategoryMenu','Category'); updateOvBtn('overviewFilterTargetBtn','overviewFilterTargetMenu','Target'); updateOvBtn('overviewFilterFilterBtn','overviewFilterFilterMenu','Filter'); - hideNotAssigned.val(''); - api.draw(); - }); - } - }); - jQuery('#showAllAssignments').prop('checked', false); - jQuery('#showAllAssignments').on('change', function() { - var dt = jQuery('#allAssignmentsTable').DataTable(); - var paginateControls = jQuery('#allAssignmentsTable').closest('.dataTables_wrapper').find('.dataTables_paginate, .dataTables_info'); - if (this.checked) { dt.page.len(-1); paginateControls.hide(); } else { dt.page.len(25); paginateControls.show(); } - dt.draw(); - }); - } - if (jQuery && jQuery.fn.DataTable && jQuery('#filtersTable').length > 0) { - var filtersTable = jQuery('#filtersTable').DataTable({ - responsive: true, pageLength: 10, order: [[3, 'desc']], - dom: 'Bfrtip', buttons: ['copy', 'csv', 'excel', 'pdf', 'print'], - columnDefs: [ - { targets: [0,1,2,3], className: 'text-start' }, - { targets: 0, width: '20%' }, { targets: 1, width: '40%' }, { targets: 2, width: '15%' }, - { targets: 3, width: '25%' } - ] - }); - jQuery('#showAllFilters').prop('checked', false); - jQuery('#showAllFilters').on('change', function() { - var isShowAll = this.checked; - var paginateControls = jQuery('#filtersTable').closest('.dataTables_wrapper').find('.dataTables_paginate, .dataTables_info'); - if (isShowAll) { filtersTable.page.len(-1); paginateControls.hide(); } - else { filtersTable.page.len(10); paginateControls.show(); } - filtersTable.draw(); - }); - } -}); -'@ - $html = @" - - - - - - $TenantName Intune Enrollment Flow Visualization - - - - - - - - - - - - - - -
-
- -
-
-
-
-
-
- -

$TenantName Enrollment Flow Visualization

-
-
- - Report generated on: $(Get-Date -Format 'MMMM dd, yyyy \a\t HH:mm') -
-
- -
-
-$fragment -
+ + # Calculate stats for tiles + $totalPolicies = if ($PolicyAssignments) { ($PolicyAssignments | Select-Object -Property PolicyName -Unique).Count } else { 0 } + $unassignedPolicies = if ($PolicyAssignments) { ($PolicyAssignments | Where-Object { $_.AssignmentType -eq "Not Assigned" } | Select-Object -Property PolicyName -Unique).Count } else { 0 } + $totalAssignments = if ($PolicyAssignments) { $PolicyAssignments.Count } else { 0 } + $filtersUsed = ($PolicyAssignments | Where-Object { $_.FilterName -and $_.FilterName -ne "No Filter" -and $_.FilterName -ne "Filter Not Found" } | Select-Object -Property FilterName -Unique).Count + + $statsCardsHtml = @" +
+
TOTAL POLICIES
+
$totalPolicies
+
Policies, apps & scripts
-
-
-
-
+
+ + - - "@ + + $html = New-RKSolutionsReportTemplate ` + -TenantName $TenantName ` + -ReportTitle 'Enrollment' ` + -ReportSlug 'intune-enrollment-overview' ` + -Eyebrow 'INTUNE ENROLLMENT FLOWS' ` + -Lede 'Assignment overview across all Intune policy types with group and filter targeting.' ` + -Tags @('Intune', 'Enrollment', 'Assignments') ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml + $outDir = Split-Path -Parent $OutputPath if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } if (-not [System.IO.Path]::HasExtension($OutputPath)) { $OutputPath = $OutputPath + ".html" } @@ -2732,139 +2560,194 @@ function New-DeviceVisualizationHtmlReport { ) if (-not $AppliedFlowHtml) { $AppliedFlowHtml = Get-AppliedFlowHtmlFragment -EvaluatedAssignments $EvaluatedAssignments -DeviceName $DeviceName -DeviceGroupDetails $DeviceGroupDetails -DevicePlatform $DevicePlatform -IsCloudPC:$IsCloudPC -DeviceGroupIds $DeviceGroupIds -UserGroupIds $UserGroupIds } $architectureFragment = Get-ArchitectureDiagramFragment -EvaluatedAssignments $EvaluatedAssignments -DeviceName $DeviceName -DeviceGroupDetails $DeviceGroupDetails -IntuneDeviceId $IntuneDeviceId -EntraDeviceId $EntraDeviceId -DevicePlatform $DevicePlatform -IsCloudPC:$IsCloudPC -DeviceGroupIds $DeviceGroupIds -UserGroupIds $UserGroupIds - $overviewTabNav = "" - $overviewTabPane = "" - $diagramTabActive = "active" - $diagramPaneActive = "show active" - $architectureTabNav = "" + + # Calculate stats + $applied = $EvaluatedAssignments | Where-Object { $_.AppliesToDevice -eq $true } + $appliedCount = if ($applied) { ($applied | Select-Object -Property PolicyName -Unique).Count } else { 0 } + $totalPolicies = if ($EvaluatedAssignments) { ($EvaluatedAssignments | Select-Object -Property PolicyName -Unique).Count } else { 0 } + $groupCount = if ($DeviceGroupDetails) { $DeviceGroupDetails.Count } else { 0 } + $excludedCount = $totalPolicies - $appliedCount + + $statsCardsHtml = @" +
+
APPLIED POLICIES
+
$appliedCount
+
Policies targeting this device
+
+
+
TOTAL POLICIES
+
$totalPolicies
+
All evaluated policies
+
+
+
GROUP MEMBERSHIPS
+
$groupCount
+
Device group memberships
+
+
+
NOT APPLIED
+
$excludedCount
+
Excluded or not targeted
+
+"@ + + # Build tab navigation for overview + diagram + $overviewTabHtml = "" + $overviewPanelHtml = "" + $diagramTabClass = "rk-tab active" + $diagramPanelClass = "rk-panel active" if ($AssignmentOverviewFragment) { - $overviewTabNav = "
  • " - $overviewTabPane = "
    " + $AssignmentOverviewFragment + "
    " - $diagramTabActive = "" - $diagramPaneActive = "" - } - $reportDate = Get-Date -Format 'MMMM dd, yyyy HH:mm' - $titleDevice = [System.Net.WebUtility]::HtmlEncode($DeviceName) - $bannerDevice = [System.Net.WebUtility]::HtmlEncode($DeviceName) - $tenantDisplay = [System.Net.WebUtility]::HtmlEncode($TenantName) + " · " + $reportDate - $htmlTemplate = @' - - - - -Intune Device Visualization - __PH_TITLE_DEVICE__ - - - - - - - -
    -
    - -
    -
    -
    -

    Intune Device Visualization

    __PH_TENANT_DATE__

    -
    Device: __PH_DEVICE_BANNER__
    - -
    -__PH_OVERVIEW_PANE__ -
    -
    -

    Architecture overview

    -

    High-level view of identity, management, policies, and assignments for this device.

    -
    Autopilot Enrollment
    -
    __PH_ARCHITECTURE__
    -

    Enrollment flow

    -

    Policies and assignments applied to this device at each stage.

    -
    __PH_APPLIED_FLOW__
    -
    -
    -
    -
    - - - - - - - - - - - -'@ +/* --- Overview container (used by Get-AssignmentOverviewTabFragment) --- */ +.overview-container{background:var(--bg-elevated);border-radius:14px;padding:2rem;margin:1rem 0;} +.overview-container::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--accent);} +.overview-header{text-align:center;margin-bottom:2rem;background:var(--bg-warm);color:var(--text);margin-left:-2rem;margin-right:-2rem;margin-top:-2rem;padding:2rem 2rem 1.5rem;border-radius:14px 14px 0 0;border-bottom:1px dashed var(--border-dashed);} +.overview-header h2{font-family:'Playfair Display',serif;font-size:1.75rem;font-weight:800;margin-bottom:0.5rem;color:var(--text);letter-spacing:-0.02em;} +.overview-header p{font-family:'Source Serif 4',serif;font-size:1rem;color:var(--text-muted);margin:0;} +.summary-card{background:var(--bg-elevated);border:1px solid var(--border);border-radius:14px;padding:1.5rem;text-align:center;} +.summary-card .card-icon{width:50px;height:50px;border-radius:12px;display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem;font-size:1.25rem;color:#fff;} +.summary-card.border-primary .card-icon{background:var(--tile-steel);} +.summary-card.border-danger .card-icon{background:var(--tile-rose);} +.overview-tiles-row .summary-card{min-height:100px;padding:0.75rem 1rem;display:flex;flex-direction:column;justify-content:center;align-items:center;} +.overview-tiles-row .summary-card .card-icon{width:36px;height:36px;font-size:1rem;margin:0 auto 0.4rem;} +.overview-tiles-row .summary-card .card-title{font-family:'Playfair Display',serif;font-size:1.5rem;font-weight:800;margin:0.2rem 0;color:var(--text);} +.overview-tiles-row .summary-card .card-text{min-height:2em;font-size:0.8rem;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--text-muted);} +/* --- Modern table (used by overview fragment) --- */ +.modern-table-container{background:var(--bg-elevated);border-radius:14px;overflow:hidden;border:1px solid var(--border);margin:1rem 0;} +.modern-table-header{background:var(--bg-warm);padding:1rem 1.25rem;border-bottom:1px dashed var(--border-dashed);color:var(--text);} +.modern-table-header h5{margin:0;font-family:'JetBrains Mono',monospace;font-size:0.78rem;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:var(--text-body);} +.modern-table-header small{color:var(--text-muted);font-size:0.75rem;} +.modern-table-body{padding:0;} +.modern-table{margin:0;} +.modern-table thead th{background:var(--bg-warm);border:none;font-weight:600;font-size:0.75rem;padding:0.75rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-body);} +.modern-table tbody tr{border-bottom:1px solid var(--border);transition:background-color 0.2s;} +.modern-table tbody tr:hover{background-color:var(--accent-soft);} +.modern-table tbody td{padding:0.75rem;border:none;vertical-align:middle;color:var(--text-body);} +/* --- Assignment filters bar (used by overview fragment) --- */ +.assignment-filters-modern{background:var(--bg-elevated);border-bottom:1px solid var(--border);padding:1rem 1.25rem;} +.assignment-filters-modern .assignment-filters-inner{display:flex;flex-wrap:wrap;align-items:flex-end;gap:1rem;} +.assignment-filters-modern .filter-group{display:flex;flex-direction:column;gap:0.25rem;} +.assignment-filters-modern .filter-label{font-size:0.68rem;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-muted);margin:0;font-family:'JetBrains Mono',monospace;} +.assignment-filters-modern .filter-select{min-width:140px;padding:0.5rem 0.75rem;border-radius:6px;border:1px solid var(--input-border);background:var(--input-bg);font-size:0.78rem;color:var(--input-color);font-family:'JetBrains Mono',monospace;} +.assignment-filters-modern .filter-dropdown-btn{min-width:140px;text-align:left;font-family:'JetBrains Mono',monospace;font-size:0.78rem;} +.assignment-filters-modern .filter-checkbox-dropdown{max-height:260px;overflow-y:auto;padding:0.25rem;} +.assignment-filters-modern .filter-checkbox-dropdown .dropdown-item{white-space:nowrap;cursor:pointer;display:flex;align-items:center;gap:0.5rem;} +.assignment-filters-modern .filter-checkbox-dropdown .dropdown-item input{margin:0;cursor:pointer;} +.assignment-filters-modern .filter-checkbox-dropdown label{margin:0;cursor:pointer;width:100%;} +.assignment-filters-modern .filter-reset-btn{display:inline-flex;align-items:center;gap:0.35rem;padding:0.5rem 0.9rem;border-radius:6px;border:1px solid var(--button-border);background:var(--button-bg);font-size:0.78rem;color:var(--button-color);cursor:pointer;height:2.15rem;font-family:'JetBrains Mono',monospace;} +.assignment-filters-modern .filter-reset-btn:hover{background:var(--button-hover-bg);} +[data-theme="dark"] .assignment-filters-modern .filter-dropdown-btn{background:var(--input-bg);border-color:var(--input-border);color:var(--input-color);} +[data-theme="dark"] .assignment-filters-modern .filter-dropdown-btn:hover,[data-theme="dark"] .assignment-filters-modern .filter-dropdown-btn:focus,[data-theme="dark"] .assignment-filters-modern .filter-dropdown-btn.show{background:var(--bg-warm);border-color:var(--border);color:var(--text-body);} +[data-theme="dark"] .assignment-filters-modern .filter-checkbox-dropdown{background:var(--bg-elevated);border-color:var(--border);} +[data-theme="dark"] .assignment-filters-modern .filter-checkbox-dropdown .dropdown-item{color:var(--text-body);} +[data-theme="dark"] .assignment-filters-modern .filter-checkbox-dropdown .dropdown-item:hover{background:var(--bg-warm);color:var(--text-body);} +[data-theme="dark"] .assignment-filters-modern .filter-checkbox-dropdown label{color:var(--text-body);} +[data-theme="dark"] .assignment-filters-modern .filter-checkbox-dropdown input.filter-cb{accent-color:var(--accent);} +"@ + + $html = New-RKSolutionsReportTemplate ` + -TenantName $TenantName ` + -ReportTitle 'Enrollment' ` + -ReportSlug 'intune-enrollment-device' ` + -Eyebrow 'DEVICE ENROLLMENT FLOW' ` + -Lede 'Device-specific enrollment flow with policy assignments, group membership, and filter evaluation.' ` + -Tags @('Intune', 'Device', 'Enrollment') ` + -StatsCardsHtml $statsCardsHtml ` + -BodyContentHtml $bodyContentHtml ` + -CustomCss $customCss + $outDir = Split-Path -Parent $OutputPath if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } if (-not [System.IO.Path]::HasExtension($OutputPath)) { $OutputPath = $OutputPath + ".html" } - $html = $htmlTemplate.Replace('__PH_TITLE_DEVICE__', $titleDevice).Replace('__PH_TENANT_DATE__', $tenantDisplay).Replace('__PH_DEVICE_BANNER__', $bannerDevice).Replace('__PH_OVERVIEW_NAV__', $overviewTabNav).Replace('__PH_OVERVIEW_PANE__', $overviewTabPane).Replace('__PH_DIAGRAM_TAB_ACTIVE__', $diagramTabActive).Replace('__PH_DIAGRAM_PANE_ACTIVE__', $diagramPaneActive).Replace('__PH_APPLIED_FLOW__', $AppliedFlowHtml).Replace('__PH_ARCHITECTURE_NAV__', $architectureTabNav).Replace('__PH_ARCHITECTURE__', $architectureFragment) [System.IO.File]::WriteAllText($OutputPath, $html, [System.Text.UTF8Encoding]::new($false)) $OutputPath } From 87de0261ea91254066db981acb7221687a451924 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 3 May 2026 22:15:17 +0200 Subject: [PATCH 07/11] feat: update manifest, tests, and docs for v1.1.0 - Bump version to 1.1.0, correct PowerShellVersion to 7.0 - Add Get-CustomSecurityAttributesReport to FunctionsToExport - Add CustomSecAttributeAssignment.Read.All to PERMISSIONS.md - Add new cmdlet to consistency test parameter checks - Update release notes with rebrand and new cmdlet info Co-Authored-By: Claude Opus 4.6 (1M context) --- Tests/Consistency.Tests.ps1 | 1 + docs/PERMISSIONS.md | 19 +++++++++++++++---- module/RKSolutions.psd1 | 12 +++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index b694b47..46c73d0 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -64,6 +64,7 @@ Describe 'Consistency contract' { 'Get-IntuneAnomaliesReport' = @('ExportPath') 'Get-EntraAdminRolesReport' = @('ExportPath') 'Get-M365LicenseAssignmentReport' = @('ExportPath') + 'Get-CustomSecurityAttributesReport' = @('AttributeSet', 'ExportPath') } } diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 3ee53d4..82c6be0 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -11,6 +11,7 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | Permission | Used by | |------------|---------| | **AuditLog.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | +| **CustomSecAttributeAssignment.Read.All** | Get-CustomSecurityAttributesReport | | **CloudLicensing.Read** | Get-M365LicenseAssignmentReport | | **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | | **Device.Read.All** | Get-IntuneEnrollmentFlowsReport | @@ -21,13 +22,13 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | **Directory.Read.All** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | | **Group.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-M365LicenseAssignmentReport | | **GroupMember.Read.All** | Get-M365LicenseAssignmentReport | -| **Mail.Send** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport (optional, for email report) | -| **Organization.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | +| **Mail.Send** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport (optional, for email report) | +| **Organization.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | | **PrivilegedEligibilitySchedule.Read.AzureADGroup** | Get-EntraAdminRolesReport | | **RoleAssignmentSchedule.Read.Directory** | Get-EntraAdminRolesReport | | **RoleManagement.Read.Directory** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | | **User.Read** | Get-IntuneAnomaliesReport (minimum for interactive) | -| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-M365LicenseAssignmentReport | +| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | --- @@ -54,7 +55,8 @@ Default `-RequiredScopes` is the **union** of all scopes below so one connection - PrivilegedEligibilitySchedule.Read.AzureADGroup - Mail.Send - CloudLicensing.Read -- CloudPC.Read.All +- CloudPC.Read.All +- CustomSecAttributeAssignment.Read.All --- @@ -110,6 +112,15 @@ Default `-RequiredScopes` is the **union** of all scopes below so one connection --- +### Get-CustomSecurityAttributesReport + +- CustomSecAttributeAssignment.Read.All +- User.Read.All +- Organization.Read.All +- Mail.Send + +--- + ## Notes - **Application vs Delegated:** These are typically **Application** or **Delegated** depending on whether you connect with client secret/certificate (application) or interactive/device code (delegated). Configure the same permission names in the Azure app registration under Microsoft Graph. diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index 44be347..a44b477 100644 --- a/module/RKSolutions.psd1 +++ b/module/RKSolutions.psd1 @@ -1,12 +1,12 @@ @{ RootModule = 'RKSolutions.psm1' - ModuleVersion = '1.0.1' + ModuleVersion = '1.1.0' GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' Author = 'Roy Klooster' CompanyName = 'RK Solutions' Copyright = '(c) 2026 Roy Klooster - RK Solutions. All rights reserved.' - Description = 'PowerShell module consolidating Intune Enrollment Flows, Intune Anomalies, Entra Admin Roles, and M365 License Assignment reports. Connects to Microsoft Graph and generates HTML/CSV reports.' - PowerShellVersion = '5.1' + Description = 'PowerShell module consolidating Intune Enrollment Flows, Intune Anomalies, Entra Admin Roles, M365 License Assignment, and Custom Security Attributes reports. Connects to Microsoft Graph and generates branded HTML reports with the rksolutions.nl editorial design.' + PowerShellVersion = '7.0' RequiredModules = @('Microsoft.Graph.Authentication') FunctionsToExport = @( 'Connect-RKGraph', @@ -14,17 +14,19 @@ 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', 'Get-EntraAdminRolesReport', - 'Get-M365LicenseAssignmentReport' + 'Get-M365LicenseAssignmentReport', + 'Get-CustomSecurityAttributesReport' ) CmdletsToExport = @() VariablesToExport = @() AliasesToExport = @() PrivateData = @{ PSData = @{ - Tags = @('RKSolutions', 'Microsoft365', 'MicrosoftIntune', 'MicrosoftEntraID', 'MicrosoftGraph', 'DeviceManagement', 'Reporting') + Tags = @('RKSolutions', 'Microsoft365', 'MicrosoftIntune', 'MicrosoftEntraID', 'MicrosoftGraph', 'DeviceManagement', 'Reporting', 'CustomSecurityAttributes') LicenseUri = 'https://opensource.org/licenses/MIT' ProjectUri = 'https://github.com/royklo/RKSolutions-Module' ReleaseNotes = @' +1.1.0 - Added Get-CustomSecurityAttributesReport cmdlet. Rebranded all HTML reports with rksolutions.nl editorial parchment design (shared template with light/dark themes, Playfair Display/Source Serif 4/JetBrains Mono typography). Added CustomSecAttributeAssignment.Read.All scope. Corrected PowerShellVersion to 7.0. 1.0.1 - Requires PowerShell 7.0 or higher. Fixed encoding issues for Windows compatibility. Improved error messaging when running on unsupported PowerShell versions. 1.0.0 - Initial module release. Consolidates Intune Enrollment Flows, Intune Anomalies, Entra Admin Roles, and M365 License Assignment reports into a single module. '@ From 780a4e0066549c290e40dfb8b5570e395535184d Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 4 May 2026 08:16:49 +0200 Subject: [PATCH 08/11] style: apply vivid branding to shared report template Brighter, more energetic color palette while staying on-brand: - Light: accent #c06828, gradient stat tiles, vivid badges, whiter bg - Dark: accent #f0a850, gradient tiles, higher contrast text #f0e8d8, brighter badges (#60e068 green, #f0b840 amber, #f87068 red) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Private/New-RKSolutionsReportTemplate.ps1 | 198 +++++++++--------- 1 file changed, 99 insertions(+), 99 deletions(-) diff --git a/module/Private/New-RKSolutionsReportTemplate.ps1 b/module/Private/New-RKSolutionsReportTemplate.ps1 index c92160c..0ab7114 100644 --- a/module/Private/New-RKSolutionsReportTemplate.ps1 +++ b/module/Private/New-RKSolutionsReportTemplate.ps1 @@ -90,82 +90,82 @@ $StatsCardsHtml /* --- Light Theme (default) --- */ :root { - --bg-base: #fcfbf8; - --bg-elevated: #f3f0e8; - --bg-warm: #e8e4dc; - --bg-deep: #f3f0e8; - --border: #e2ddd2; - --border-dashed: #ddd8ce; - --text: #181410; - --text-body: #46423a; - --text-muted: #787468; - --text-dim: #a8a298; - --accent: #84441c; - --accent-hover: #a85b1d; - --accent-soft: #fceadb; - --success: #2d7a3a; - --warn: #c46a1a; - --error: #c0392b; - --tile-rust: #84441c; - --tile-olive: #4a6830; - --tile-steel: #3e5c78; - --tile-rose: #8a3e38; - --toggle-bg: #ddd8ce; - --input-bg: #fcfbf8; - --input-border: #e2ddd2; - --input-color: #46423a; - --button-bg: #f3f0e8; - --button-color: #46423a; - --button-border: #e2ddd2; - --button-hover-bg: #e8e4dc; -} - -/* --- Dark Theme (warm parchment) --- */ + --bg-base: #fefefe; + --bg-elevated: #f0ece4; + --bg-warm: #eae4d8; + --bg-deep: #f0ece4; + --border: #ddd6c8; + --border-dashed: #ddd6c8; + --text: #1a1510; + --text-body: #3a3228; + --text-muted: #807060; + --text-dim: #a89878; + --accent: #c06828; + --accent-hover: #d87830; + --accent-soft: #fde8d4; + --success: #28904a; + --warn: #d88020; + --error: #d83830; + --tile-rust: #c06828; + --tile-olive: #2e9040; + --tile-steel: #2878b8; + --tile-rose: #d04848; + --toggle-bg: #ddd6c8; + --input-bg: #fefefe; + --input-border: #ddd6c8; + --input-color: #3a3228; + --button-bg: #f0ece4; + --button-color: #3a3228; + --button-border: #ddd6c8; + --button-hover-bg: #eae4d8; +} + +/* --- Dark Theme (warm parchment, vivid) --- */ [data-theme="dark"] { - --bg-base: #1a1710; - --bg-elevated: #252118; - --bg-warm: #2a2318; - --bg-deep: #151210; - --border: #3a3228; - --border-dashed: #3a3228; - --text: #e0d8c8; - --text-body: #e0d8c8; - --text-muted: #8a7a60; - --text-dim: #5a5040; - --accent: #c8a060; - --accent-hover: #d8b070; - --accent-soft: rgba(200,160,96,0.08); - --success: #6abf6a; - --warn: #d4a840; - --error: #e06050; - --tile-rust-bg: #3a2810; - --tile-rust-border: #5a3c18; - --tile-rust-text: #e8c080; - --tile-rust-eyebrow: #c8a060; - --tile-rust-caption: #8a6a38; - --tile-olive-bg: #1a2c10; - --tile-olive-border: #2a4418; - --tile-olive-text: #a0d870; - --tile-olive-eyebrow: #7aa850; - --tile-olive-caption: #4a6a28; - --tile-steel-bg: #10202e; - --tile-steel-border: #183448; - --tile-steel-text: #78c0e8; - --tile-steel-eyebrow: #5898c0; - --tile-steel-caption: #38708a; - --tile-rose-bg: #2e1410; - --tile-rose-border: #4a2018; - --tile-rose-text: #e88878; - --tile-rose-eyebrow: #c06058; - --tile-rose-caption: #884038; - --toggle-bg: #3a3228; - --input-bg: #1a1710; - --input-border: #3a3228; - --input-color: #e0d8c8; - --button-bg: #252118; - --button-color: #e0d8c8; - --button-border: #3a3228; - --button-hover-bg: #2a2318; + --bg-base: #1c1916; + --bg-elevated: #282420; + --bg-warm: #302a24; + --bg-deep: #161410; + --border: #403830; + --border-dashed: #403830; + --text: #f0e8d8; + --text-body: #f0e8d8; + --text-muted: #b0a088; + --text-dim: #685840; + --accent: #f0a850; + --accent-hover: #f8b860; + --accent-soft: rgba(240,168,80,0.1); + --success: #60e068; + --warn: #f0b840; + --error: #f87068; + --tile-rust-bg: #4a2e10; + --tile-rust-border: #7a5028; + --tile-rust-text: #f8c870; + --tile-rust-eyebrow: #e0a850; + --tile-rust-caption: #b08038; + --tile-olive-bg: #183818; + --tile-olive-border: #286828; + --tile-olive-text: #80f088; + --tile-olive-eyebrow: #60d060; + --tile-olive-caption: #408840; + --tile-steel-bg: #102838; + --tile-steel-border: #205878; + --tile-steel-text: #70d0f8; + --tile-steel-eyebrow: #58b0e0; + --tile-steel-caption: #3890a8; + --tile-rose-bg: #381818; + --tile-rose-border: #683030; + --tile-rose-text: #f88880; + --tile-rose-eyebrow: #e06860; + --tile-rose-caption: #b04840; + --toggle-bg: #403830; + --input-bg: #1c1916; + --input-border: #403830; + --input-color: #f0e8d8; + --button-bg: #282420; + --button-color: #f0e8d8; + --button-border: #403830; + --button-hover-bg: #302a24; } /* --- Base --- */ @@ -332,38 +332,38 @@ input:checked + .rk-theme-toggle-slider:before { transform: translateX(18px); } margin-top: 4px; } -/* Light mode tiles: solid bg, white text */ -.rk-stat-tile.t-rust { background: var(--tile-rust); color: #fff; } -.rk-stat-tile.t-rust .rk-stat-eyebrow { opacity: 0.85; } -.rk-stat-tile.t-rust .rk-stat-caption { opacity: 0.75; } -.rk-stat-tile.t-olive { background: var(--tile-olive); color: #fff; } -.rk-stat-tile.t-olive .rk-stat-eyebrow { opacity: 0.85; } -.rk-stat-tile.t-olive .rk-stat-caption { opacity: 0.75; } -.rk-stat-tile.t-steel { background: var(--tile-steel); color: #fff; } -.rk-stat-tile.t-steel .rk-stat-eyebrow { opacity: 0.85; } -.rk-stat-tile.t-steel .rk-stat-caption { opacity: 0.75; } -.rk-stat-tile.t-rose { background: var(--tile-rose); color: #fff; } -.rk-stat-tile.t-rose .rk-stat-eyebrow { opacity: 0.85; } -.rk-stat-tile.t-rose .rk-stat-caption { opacity: 0.75; } - -/* Dark mode tiles: tinted bg, colored text */ +/* Light mode tiles: gradient bg, white text */ +.rk-stat-tile.t-rust { background: linear-gradient(135deg, #d06828, #e08040); color: #fff; } +.rk-stat-tile.t-rust .rk-stat-eyebrow { opacity: 0.92; } +.rk-stat-tile.t-rust .rk-stat-caption { opacity: 0.85; } +.rk-stat-tile.t-olive { background: linear-gradient(135deg, #2e9040, #48b060); color: #fff; } +.rk-stat-tile.t-olive .rk-stat-eyebrow { opacity: 0.92; } +.rk-stat-tile.t-olive .rk-stat-caption { opacity: 0.85; } +.rk-stat-tile.t-steel { background: linear-gradient(135deg, #2878b8, #4098d0); color: #fff; } +.rk-stat-tile.t-steel .rk-stat-eyebrow { opacity: 0.92; } +.rk-stat-tile.t-steel .rk-stat-caption { opacity: 0.85; } +.rk-stat-tile.t-rose { background: linear-gradient(135deg, #d04848, #e06868); color: #fff; } +.rk-stat-tile.t-rose .rk-stat-eyebrow { opacity: 0.92; } +.rk-stat-tile.t-rose .rk-stat-caption { opacity: 0.85; } + +/* Dark mode tiles: gradient tinted bg, bright colored text */ [data-theme="dark"] .rk-stat-tile.t-rust { - background: var(--tile-rust-bg); border: 1px solid var(--tile-rust-border); color: var(--tile-rust-text); + background: linear-gradient(135deg, var(--tile-rust-bg), #5a3818); border: 1px solid var(--tile-rust-border); color: var(--tile-rust-text); } [data-theme="dark"] .rk-stat-tile.t-rust .rk-stat-eyebrow { color: var(--tile-rust-eyebrow); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-rust .rk-stat-caption { color: var(--tile-rust-caption); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-olive { - background: var(--tile-olive-bg); border: 1px solid var(--tile-olive-border); color: var(--tile-olive-text); + background: linear-gradient(135deg, var(--tile-olive-bg), #204820); border: 1px solid var(--tile-olive-border); color: var(--tile-olive-text); } [data-theme="dark"] .rk-stat-tile.t-olive .rk-stat-eyebrow { color: var(--tile-olive-eyebrow); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-olive .rk-stat-caption { color: var(--tile-olive-caption); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-steel { - background: var(--tile-steel-bg); border: 1px solid var(--tile-steel-border); color: var(--tile-steel-text); + background: linear-gradient(135deg, var(--tile-steel-bg), #183848); border: 1px solid var(--tile-steel-border); color: var(--tile-steel-text); } [data-theme="dark"] .rk-stat-tile.t-steel .rk-stat-eyebrow { color: var(--tile-steel-eyebrow); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-steel .rk-stat-caption { color: var(--tile-steel-caption); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-rose { - background: var(--tile-rose-bg); border: 1px solid var(--tile-rose-border); color: var(--tile-rose-text); + background: linear-gradient(135deg, var(--tile-rose-bg), #482020); border: 1px solid var(--tile-rose-border); color: var(--tile-rose-text); } [data-theme="dark"] .rk-stat-tile.t-rose .rk-stat-eyebrow { color: var(--tile-rose-eyebrow); opacity: 1; } [data-theme="dark"] .rk-stat-tile.t-rose .rk-stat-caption { color: var(--tile-rose-caption); opacity: 1; } @@ -476,11 +476,11 @@ table.dataTable, .table { .rk-badge-na { background: var(--bg-warm); color: var(--text-muted); } .rk-badge-accent { background: var(--accent); color: #fff; } -[data-theme="dark"] .rk-badge-ok { background: rgba(90,160,90,0.2); color: #6abf6a; border: 1px solid rgba(90,160,90,0.3); } -[data-theme="dark"] .rk-badge-warn { background: rgba(200,150,50,0.18); color: #d4a840; border: 1px solid rgba(200,150,50,0.3); } -[data-theme="dark"] .rk-badge-error { background: rgba(200,80,70,0.18); color: #e06050; border: 1px solid rgba(200,80,70,0.3); } -[data-theme="dark"] .rk-badge-na { background: rgba(140,120,90,0.15); color: #8a7a60; border: 1px solid rgba(140,120,90,0.25); } -[data-theme="dark"] .rk-badge-accent { background: rgba(200,160,96,0.2); color: #c8a060; border: 1px solid rgba(200,160,96,0.3); } +[data-theme="dark"] .rk-badge-ok { background: rgba(80,210,90,0.2); color: #60e068; border: 1px solid rgba(80,210,90,0.3); } +[data-theme="dark"] .rk-badge-warn { background: rgba(240,170,50,0.2); color: #f0b840; border: 1px solid rgba(240,170,50,0.3); } +[data-theme="dark"] .rk-badge-error { background: rgba(240,80,60,0.2); color: #f87068; border: 1px solid rgba(240,80,60,0.3); } +[data-theme="dark"] .rk-badge-na { background: rgba(160,140,100,0.15); color: #b0a088; border: 1px solid rgba(160,140,100,0.25); } +[data-theme="dark"] .rk-badge-accent { background: rgba(240,168,80,0.2); color: #f0a850; border: 1px solid rgba(240,168,80,0.3); } /* --- Report Tabs --- */ .rk-tabs { From efd5a7ffc8a811e8dbe95969f914677b80d5ddae Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 4 May 2026 08:20:02 +0200 Subject: [PATCH 09/11] fix: table backgrounds now follow light/dark theme correctly - Card body gets explicit var(--bg-base) background - Table and td inherit background from row instead of forcing white - Even rows use var(--bg-elevated) in light, var(--bg-deep) in dark - Override Bootstrap --bs-table-bg/striped/hover variables to transparent - DataTables wrapper backgrounds set to transparent Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Private/New-RKSolutionsReportTemplate.ps1 | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/module/Private/New-RKSolutionsReportTemplate.ps1 b/module/Private/New-RKSolutionsReportTemplate.ps1 index 0ab7114..aa956ca 100644 --- a/module/Private/New-RKSolutionsReportTemplate.ps1 +++ b/module/Private/New-RKSolutionsReportTemplate.ps1 @@ -422,13 +422,17 @@ input:checked + .rk-theme-toggle-slider:before { transform: translateX(18px); } gap: 12px; transition: background-color 0.3s, color 0.3s; } -.rk-card-body { padding: 20px; } +.rk-card-body { + padding: 20px; + background-color: var(--bg-base); + transition: background-color 0.3s; +} table.dataTable, .table { width: 100% !important; border-collapse: collapse !important; color: var(--text-body) !important; - background-color: transparent !important; + background-color: var(--bg-base) !important; } .table thead th { background-color: var(--bg-warm) !important; @@ -447,6 +451,7 @@ table.dataTable, .table { vertical-align: middle; border: 1px solid var(--border) !important; color: var(--text-body) !important; + background-color: inherit !important; font-family: 'Source Serif 4', serif; transition: background-color 0.3s, color 0.3s, border-color 0.3s; } @@ -455,10 +460,28 @@ table.dataTable, .table { font-size: 0.82rem; } .table tbody tr { background-color: var(--bg-base) !important; transition: background-color 0.2s; } +.table tbody tr:nth-child(even) { background-color: var(--bg-elevated) !important; } +[data-theme="dark"] .table tbody tr { background-color: var(--bg-base) !important; } [data-theme="dark"] .table tbody tr:nth-child(even) { background-color: var(--bg-deep) !important; } .table tbody tr:hover { background-color: var(--accent-soft) !important; } .table tbody tr:hover td { background-color: inherit !important; } +/* Override Bootstrap table backgrounds */ +.table-striped > tbody > tr:nth-of-type(odd), +.table-striped > tbody > tr:nth-of-type(even), +.table-striped > tbody > tr > td, +.table > tbody > tr > td, +.table > tbody > tr { + background-color: inherit !important; + --bs-table-bg: transparent; + --bs-table-striped-bg: transparent; + --bs-table-hover-bg: transparent; +} + +/* DataTables wrapper backgrounds */ +.dataTables_wrapper { background-color: transparent !important; color: var(--text-body) !important; } +.dataTables_wrapper .row { background-color: transparent !important; } + /* --- Status Badges --- */ .rk-badge { display: inline-block; From 1e442897c3452551617911d39da38d866922acd3 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 4 May 2026 08:22:38 +0200 Subject: [PATCH 10/11] fix: recalculate DataTable column widths on tab switch Tables in hidden panels (display:none) can't calculate their widths. Now calls columns.adjust() after making a tab panel visible, so Subscription Overview and Disabled Users tables match the first tab. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/Private/New-RKSolutionsReportTemplate.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/module/Private/New-RKSolutionsReportTemplate.ps1 b/module/Private/New-RKSolutionsReportTemplate.ps1 index aa956ca..8db80dc 100644 --- a/module/Private/New-RKSolutionsReportTemplate.ps1 +++ b/module/Private/New-RKSolutionsReportTemplate.ps1 @@ -760,13 +760,17 @@ $CustomCss } }); - // Tab switching + // Tab switching - recalculate DataTable widths after showing hidden panels `$(document).on('click', '.rk-tab', function() { const target = `$(this).data('target'); `$(this).closest('.rk-tabs').find('.rk-tab').removeClass('active'); `$(this).addClass('active'); `$(this).closest('.rk-tabs').siblings('.rk-panel').removeClass('active'); `$('#' + target).addClass('active'); + // DataTables can't calculate column widths when hidden; adjust after tab is visible + `$('#' + target).find('table.dataTable').each(function() { + `$(this).DataTable().columns.adjust(); + }); }); }); From fcf69ff44650d8deb31815bcfa2150f0c2636af7 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Fri, 8 May 2026 10:23:20 +0200 Subject: [PATCH 11/11] fix: security hardening, code quality, and report improvements Security (P0): - XSS: HTML-encode all Graph data in all 5 HTML report generators - XSS: fix JS filter dropdown injection with safe jQuery DOM API - Validate $From as email/GUID in Send-EmailWithAttachment Code quality (P1): - Replace array += with List[PSObject].Add() in hot loops - Add Write-Verbose to empty catch blocks in IntuneEnrollmentFlows - Add Write-Warning when Graph paging hits 10k result cap - Fix file deletion before email confirmed in IntuneAnomaliesReport - Add null-check for undefined $mermaidDiagram variable Maintenance (P2/P3): - Sanitize tenant name in export path for IntuneAnomalies - Fix typo: OperationSystemEdtionOverview -> OperatingSystemEditionOverview - Wrap Invoke-Item in try/catch for cross-platform safety - Use temp file instead of hardcoded C:\temp path - Remove unused $Filters/$AttributeNames params - Remove dead OS detection variables in Export-Results - Initialize $emailSent before conditional block - Translate Dutch comments to English - Fix double-encoded HTML arrow entity in PIM audit logs Report improvements: - Rename New-RKSolutionsReportTemplate -> Get-RKSolutionsReportTemplate - PIM Audit Logs: fix column widths with table-layout:fixed - M365 License: replace assignment type badges with plain text --- module/Private/CustomSecurityAttributes.ps1 | 629 +++++++++++++----- module/Private/EntraAdminRoles.ps1 | 129 ++-- module/Private/Export-Results.ps1 | 26 - ....ps1 => Get-RKSolutionsReportTemplate.ps1} | 413 +++++++----- module/Private/IntuneAnomalies.ps1 | 158 +++-- module/Private/IntuneEnrollmentFlows.ps1 | 167 ++--- .../Private/Invoke-GraphRequestWithPaging.ps1 | 5 +- module/Private/M365License.ps1 | 66 +- module/Private/Send-EmailWithAttachment.ps1 | 9 +- module/Public/Connect-RKGraph.ps1 | 3 +- .../Get-CustomSecurityAttributesReport.ps1 | 45 +- module/Public/Get-IntuneAnomaliesReport.ps1 | 7 +- .../Get-IntuneEnrollmentFlowsReport.ps1 | 10 +- module/RKSolutions.psd1 | 4 +- module/RKSolutions.psm1 | 2 +- 15 files changed, 1018 insertions(+), 655 deletions(-) rename module/Private/{New-RKSolutionsReportTemplate.ps1 => Get-RKSolutionsReportTemplate.ps1} (65%) diff --git a/module/Private/CustomSecurityAttributes.ps1 b/module/Private/CustomSecurityAttributes.ps1 index ec906ac..1e1c438 100644 --- a/module/Private/CustomSecurityAttributes.ps1 +++ b/module/Private/CustomSecurityAttributes.ps1 @@ -2,270 +2,537 @@ function Get-CustomSecurityAttributeData { param( - [Parameter(Mandatory = $true)] - [string]$AttributeSet, - - [Parameter(Mandatory = $false)] - [string[]]$AttributeNames, - [Parameter(Mandatory = $false)] - [hashtable]$Filters, + [string]$AttributeSet, [Parameter(Mandatory = $false)] [switch]$DebugMode ) - Write-Host "Retrieving attribute definitions for '$AttributeSet'..." -ForegroundColor Cyan - $attributeFilter = [uri]::EscapeDataString("attributeSet eq '$AttributeSet'") - $attrDefUri = "https://graph.microsoft.com/beta/directory/customSecurityAttributeDefinitions?`$filter=$attributeFilter" - $attributeDefinitions = Invoke-MgGraphRequest -Method GET -Uri $attrDefUri -OutputType PSObject - - if ($attributeDefinitions.value.Count -eq 0) { - throw "No custom security attribute definitions found for attribute set '$AttributeSet'" - } - - # If AttributeNames not specified, use all attributes from the set - if (-not $AttributeNames) { - $AttributeNames = $attributeDefinitions.value | ForEach-Object { $_.name } - Write-Host "Using all attributes: $($AttributeNames -join ', ')" -ForegroundColor Yellow - } else { - Write-Host "Selected attributes: $($AttributeNames -join ', ')" -ForegroundColor Cyan + # Helper: query entities with paging and extract custom security attributes + function Get-EntitiesWithAttributes { + param([string]$Uri, [string]$EntityType, [hashtable]$Headers = @{}) + $all = [System.Collections.Generic.List[object]]::new() + try { + $results = Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $Headers -OutputType PSObject + if ($results.value) { $all.AddRange($results.value) } + while ($results.'@odata.nextLink') { + $results = Invoke-MgGraphRequest -Method GET -Uri $results.'@odata.nextLink' -Headers $Headers -OutputType PSObject + if ($results.value) { $all.AddRange($results.value) } + } + } catch { + Write-Host " WARNING: Could not query $EntityType - $($_.Exception.Message)" -ForegroundColor Yellow + } + return $all } - # Build filter query - $filterConditions = @() - if ($Filters) { - foreach ($key in $Filters.Keys) { - $value = $Filters[$key] - $filterConditions += "customSecurityAttributes/$AttributeSet/$key eq '$value'" + # ===== Step 1: Query all three entity types ===== + Write-Host "Querying users..." -ForegroundColor Cyan + $allUsers = Get-EntitiesWithAttributes ` + -Uri "https://graph.microsoft.com/v1.0/users?`$count=true&`$select=id,displayName,userPrincipalName,customSecurityAttributes&`$top=999" ` + -EntityType "users" -Headers @{ ConsistencyLevel = 'eventual' } + Write-Host " Found $($allUsers.Count) user(s)" -ForegroundColor Green + + Write-Host "Querying devices..." -ForegroundColor Cyan + $allDevices = Get-EntitiesWithAttributes ` + -Uri "https://graph.microsoft.com/beta/devices?`$select=id,displayName,operatingSystem,customSecurityAttributes&`$top=999" ` + -EntityType "devices" + Write-Host " Found $($allDevices.Count) device(s)" -ForegroundColor Green + + Write-Host "Querying enterprise applications..." -ForegroundColor Cyan + $allApps = Get-EntitiesWithAttributes ` + -Uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=id,displayName,appId,customSecurityAttributes&`$top=999" ` + -EntityType "service principals" + Write-Host " Found $($allApps.Count) app(s)" -ForegroundColor Green + + # ===== Step 2: Discover all attribute sets from all entities ===== + Write-Host "Discovering attribute sets..." -ForegroundColor Cyan + $allAttributeSets = [System.Collections.Generic.HashSet[string]]::new() + $allEntities = @($allUsers) + @($allDevices) + @($allApps) + + foreach ($entity in $allEntities) { + if ($entity.customSecurityAttributes) { + foreach ($prop in $entity.customSecurityAttributes.PSObject.Properties) { + if ($prop.Name -ne '@odata.type' -and $prop.Value -is [System.Management.Automation.PSCustomObject]) { + [void]$allAttributeSets.Add($prop.Name) + } + } } } - if ($filterConditions.Count -eq 0) { - $firstAttr = $AttributeNames[0] - $filterQuery = "customSecurityAttributes/$AttributeSet/$firstAttr ne null" - } else { - $filterQuery = $filterConditions -join ' and ' + $sortedSets = @($allAttributeSets | Sort-Object) + if ($sortedSets.Count -eq 0) { + throw "No custom security attribute sets found in your tenant." } + Write-Host " Found attribute sets: $($sortedSets -join ', ')" -ForegroundColor Green - # Query users - $uri = "https://graph.microsoft.com/v1.0/users?`$filter=$filterQuery&`$count=true&`$select=id,displayName,userPrincipalName,customSecurityAttributes" - - Write-Host 'Querying users with custom security attributes...' -ForegroundColor Cyan - $results = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers @{ - ConsistencyLevel = 'eventual' - } -OutputType PSObject - - # Handle paging - $allUsers = [System.Collections.Generic.List[object]]::new() - if ($results.value) { $allUsers.AddRange($results.value) } - while ($results.'@odata.nextLink') { - $results = Invoke-MgGraphRequest -Method GET -Uri $results.'@odata.nextLink' -Headers @{ ConsistencyLevel = 'eventual' } -OutputType PSObject - if ($results.value) { $allUsers.AddRange($results.value) } + # If a specific set was requested, validate it exists + if ($AttributeSet -and $AttributeSet -notin $sortedSets) { + throw "Attribute set '$AttributeSet' not found. Available sets: $($sortedSets -join ', ')" } - # Process results - $userData = @() - foreach ($user in $allUsers) { - $attributeData = $user.customSecurityAttributes.$AttributeSet + # ===== Step 3: Discover attributes per set ===== + $setData = [ordered]@{} + $setsToProcess = if ($AttributeSet) { @($AttributeSet) } else { $sortedSets } + + foreach ($setName in $setsToProcess) { + $discoveredAttrs = [System.Collections.Generic.HashSet[string]]::new() + foreach ($entity in $allEntities) { + $attrData = $entity.customSecurityAttributes.$setName + if ($attrData) { + foreach ($prop in $attrData.PSObject.Properties) { + if ($prop.Name -ne '@odata.type') { + [void]$discoveredAttrs.Add($prop.Name) + } + } + } + } + $attrNames = @($discoveredAttrs | Sort-Object) + if ($attrNames.Count -eq 0) { continue } + + # Process each entity type for this set + $usersForSet = [System.Collections.Generic.List[PSObject]]::new() + $devicesForSet = [System.Collections.Generic.List[PSObject]]::new() + $appsForSet = [System.Collections.Generic.List[PSObject]]::new() + + foreach ($user in $allUsers) { + $attrData = $user.customSecurityAttributes.$setName + if (-not $attrData) { continue } + $obj = [ordered]@{ DisplayName = $user.displayName; Identifier = $user.userPrincipalName } + foreach ($a in $attrNames) { $obj[$a] = if ($attrData.$a) { $attrData.$a } else { '-' } } + $obj['ObjectId'] = $user.id + $usersForSet.Add([PSCustomObject]$obj) + } + + foreach ($device in $allDevices) { + $attrData = $device.customSecurityAttributes.$setName + if (-not $attrData) { continue } + $obj = [ordered]@{ DisplayName = $device.displayName; Identifier = if ($device.operatingSystem) { $device.operatingSystem } else { '-' } } + foreach ($a in $attrNames) { $obj[$a] = if ($attrData.$a) { $attrData.$a } else { '-' } } + $obj['ObjectId'] = $device.id + $devicesForSet.Add([PSCustomObject]$obj) + } - $userObject = [ordered]@{ - DisplayName = $user.displayName - UserPrincipalName = $user.userPrincipalName + foreach ($app in $allApps) { + $attrData = $app.customSecurityAttributes.$setName + if (-not $attrData) { continue } + $obj = [ordered]@{ DisplayName = $app.displayName; Identifier = if ($app.appId) { $app.appId } else { '-' } } + foreach ($a in $attrNames) { $obj[$a] = if ($attrData.$a) { $attrData.$a } else { '-' } } + $obj['ObjectId'] = $app.id + $appsForSet.Add([PSCustomObject]$obj) } - foreach ($attrName in $AttributeNames) { - $userObject[$attrName] = if ($attributeData.$attrName) { $attributeData.$attrName } else { '-' } + $setData[$setName] = @{ + AttributeNames = $attrNames + Users = $usersForSet + Devices = $devicesForSet + Apps = $appsForSet } - $userObject['UserId'] = $user.id - $userData += [PSCustomObject]$userObject + Write-Host " $setName : $($attrNames.Count) attributes, $($usersForSet.Count) users, $($devicesForSet.Count) devices, $($appsForSet.Count) apps" -ForegroundColor Yellow + } + + # Build overview data (coverage matrix) + $overviewData = [System.Collections.Generic.List[hashtable]]::new() + foreach ($user in $allUsers) { + if (-not $user.customSecurityAttributes) { continue } + $hasSets = @{} + foreach ($s in $setsToProcess) { $hasSets[$s] = if ($user.customSecurityAttributes.$s) { $true } else { $false } } + if (-not ($hasSets.Values -contains $true)) { continue } + $overviewData.Add(@{ Type = 'User'; Name = $user.displayName; Identifier = $user.userPrincipalName; Sets = $hasSets }) + } + foreach ($device in $allDevices) { + if (-not $device.customSecurityAttributes) { continue } + $hasSets = @{} + foreach ($s in $setsToProcess) { $hasSets[$s] = if ($device.customSecurityAttributes.$s) { $true } else { $false } } + if (-not ($hasSets.Values -contains $true)) { continue } + $overviewData.Add(@{ Type = 'Device'; Name = $device.displayName; Identifier = if ($device.operatingSystem) { $device.operatingSystem } else { '-' }; Sets = $hasSets }) + } + foreach ($app in $allApps) { + if (-not $app.customSecurityAttributes) { continue } + $hasSets = @{} + foreach ($s in $setsToProcess) { $hasSets[$s] = if ($app.customSecurityAttributes.$s) { $true } else { $false } } + if (-not ($hasSets.Values -contains $true)) { continue } + $overviewData.Add(@{ Type = 'App'; Name = $app.displayName; Identifier = if ($app.appId) { $app.appId } else { '-' }; Sets = $hasSets }) } return @{ - UserData = $userData - AttributeNames = $AttributeNames + SetData = $setData + AttributeSets = $setsToProcess + OverviewData = $overviewData + Counts = @{ + Users = ($overviewData | Where-Object { $_.Type -eq 'User' }).Count + Devices = ($overviewData | Where-Object { $_.Type -eq 'Device' }).Count + Apps = ($overviewData | Where-Object { $_.Type -eq 'App' }).Count + Sets = $setsToProcess.Count + } } } function New-CustomSecurityAttributesHTMLReport { param( - [Parameter(Mandatory = $true)] - [string]$TenantName, - - [Parameter(Mandatory = $true)] - [array]$UserData, - - [Parameter(Mandatory = $true)] - [string]$AttributeSet, - - [Parameter(Mandatory = $true)] - [string[]]$AttributeNames, - - [Parameter(Mandatory = $false)] - [string]$ExportPath + [Parameter(Mandatory = $true)] [string]$TenantName, + [Parameter(Mandatory = $true)] [hashtable]$ReportData, + [Parameter(Mandatory = $false)] [string]$ExportPath ) if (-not $ExportPath) { $ExportPath = Join-Path (Get-Location).Path "$TenantName-CustomSecurityAttributes.html" } - $totalUsers = $UserData.Count - $reportDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') - - # Calculate unique counts per attribute - $attributeStats = @{} - foreach ($attrName in $AttributeNames) { - $uniqueCount = ($UserData | Select-Object -ExpandProperty $attrName -Unique | Where-Object { $_ -ne '-' }).Count - $attributeStats[$attrName] = $uniqueCount + $exportDir = Split-Path -Path $ExportPath -Parent + if ($exportDir -and -not (Test-Path $exportDir)) { + New-Item -Path $exportDir -ItemType Directory -Force | Out-Null } - # Build stat tiles - $tileColors = @('t-rust', 't-olive', 't-steel', 't-rose') + $reportDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + $sets = $ReportData.AttributeSets + $setData = $ReportData.SetData + $overview = $ReportData.OverviewData + $counts = $ReportData.Counts + + # Stat tiles $statsCardsHtml = @" -
    -
    TOTAL USERS
    -
    $totalUsers
    -
    With $AttributeSet attributes
    -
    -"@ - $colorIdx = 1 - foreach ($attrName in ($AttributeNames | Select-Object -First 3)) { - $count = $attributeStats[$attrName] - $color = $tileColors[$colorIdx] - $statsCardsHtml += @" - -
    -
    $($attrName.ToUpper())
    -
    $count
    -
    Unique values
    -
    +
    +
    USERS
    +
    $($counts.Users)
    +
    With attributes assigned
    +
    +
    +
    DEVICES
    +
    $($counts.Devices)
    +
    With attributes assigned
    +
    +
    +
    ENTERPRISE APPS
    +
    $($counts.Apps)
    +
    With attributes assigned
    +
    +
    +
    ATTRIBUTE SETS
    +
    $($counts.Sets)
    +
    Active in tenant
    +
    "@ - $colorIdx++ + + # ===== Build tab buttons ===== + $tabButtonsHtml = ' ' + foreach ($setName in $sets) { + $tabButtonsHtml += "`n " } - # Build filter dropdowns - $colSize = [Math]::Max(12 / [Math]::Min($AttributeNames.Count, 4), 3) - $filterDropdownsHtml = '' - foreach ($attrName in $AttributeNames) { - $uniqueValues = $UserData | Select-Object -ExpandProperty $attrName -Unique | Where-Object { $_ -ne '-' } | Sort-Object - $optionsHtml = ($uniqueValues | ForEach-Object { "" }) -join "`n" - $filterDropdownsHtml += @" -
    -
    - - -
    -
    -"@ + # ===== Build Overview panel ===== + $overviewHeaders = " Type`n Name" + foreach ($s in $sets) { $overviewHeaders += "`n $s" } + + $overviewRows = '' + foreach ($item in $overview) { + $typeLabel = switch ($item.Type) { + 'User' { 'User' } + 'Device' { 'Device' } + 'App' { 'Enterprise App' } + } + $overviewRows += " `n" + $overviewRows += " $typeLabel`n" + $overviewRows += " $([System.Net.WebUtility]::HtmlEncode($item.Name))`n" + foreach ($s in $sets) { + $check = if ($item.Sets[$s]) { '' } else { '' } + $overviewRows += " $check`n" + } + $overviewRows += " `n" } - # Build table headers - $tableHeaders = " Display Name`n User Principal Name" - foreach ($attrName in $AttributeNames) { - $tableHeaders += "`n $attrName" + # Calculate coverage metrics + $totalEntities = $overview.Count + $totalSlots = $totalEntities * $sets.Count + $assignedSlots = 0 + $fullCoverageCount = 0 + foreach ($item in $overview) { + $assignedCount = ($sets | Where-Object { $item.Sets[$_] }).Count + $assignedSlots += $assignedCount + if ($assignedCount -eq $sets.Count) { $fullCoverageCount++ } } - $tableHeaders += "`n User ID" - - # Build table rows - $tableRows = '' - foreach ($user in $UserData) { - $tableRows += " `n" - $tableRows += " $($user.DisplayName)`n" - $tableRows += " $($user.UserPrincipalName)`n" - foreach ($attrName in $AttributeNames) { - $value = $user.$attrName - $displayValue = if ($value -ne '-') { $value } else { 'Not Set' } - $tableRows += " $displayValue`n" - } - $tableRows += " $($user.UserId)`n" - $tableRows += " `n" + $avgCoverage = if ($totalSlots -gt 0) { [Math]::Round(($assignedSlots / $totalSlots) * 100) } else { 0 } + $noCoverageCount = $totalSlots - $assignedSlots + + # Coverage per set + $setCoverageHtml = '' + $barColors = @('orange', 'blue', 'green', 'purple', 'red', 'orange', 'blue', 'green') + $setIdx = 0 + foreach ($s in $sets) { + $entitiesWithSet = ($overview | Where-Object { $_.Sets[$s] }).Count + $pct = if ($totalEntities -gt 0) { [Math]::Round(($entitiesWithSet / $totalEntities) * 100) } else { 0 } + $color = $barColors[$setIdx % $barColors.Count] + $setCoverageHtml += "
    $s
    ${pct}%
    `n" + $setIdx++ } - # Build body content - $bodyContentHtml = @" -
    - -
    -$filterDropdownsHtml + # Coverage per entity type + $userCount = ($overview | Where-Object { $_.Type -eq 'User' }).Count + $deviceCount = ($overview | Where-Object { $_.Type -eq 'Device' }).Count + $appCount = ($overview | Where-Object { $_.Type -eq 'App' }).Count + $entityCoverageHtml = @" +
    Users
    $userCount
    +
    Devices
    $deviceCount
    +
    Enterprise Apps
    $appCount
    +"@ + + $overviewPanelHtml = @" +
    + +
    +
    $totalEntities
    Total Entities
    +
    $($sets.Count)
    Attribute Sets
    +
    ${avgCoverage}%
    Avg Coverage
    +
    $fullCoverageCount
    Full Coverage
    +
    $noCoverageCount
    Unassigned Slots
    -
    -
    -
    - User Custom Security Attributes ($AttributeSet) -
    - - Show all + +
    +
    +
    Coverage by Attribute Set
    +$setCoverageHtml +
    +
    +
    Entities by Type
    +$entityCoverageHtml
    -
    -
    - + +
    +
    + Attribute Set Coverage Matrix +
    + + Show all +
    +
    +
    +
    -$tableHeaders +$overviewHeaders -$tableRows +$overviewRows + +
    +
    +
    +
    +"@ + + # ===== Build attribute set panels ===== + $setPanelsHtml = '' + $setScriptHtml = '' + $tableCounter = 0 + + foreach ($setName in $sets) { + $sd = $setData[$setName] + $attrNames = $sd.AttributeNames + $panelId = "panel-$($setName.ToLower())" + + $panelContent = "
    `n" + + # Build filter bar with dropdowns per attribute + $filterId = "filter_$($setName.ToLower())" + $allEntitiesForSet = @($sd.Users) + @($sd.Devices) + @($sd.Apps) + $filterDropdowns = "Filters:" + foreach ($a in $attrNames) { + $uniqueVals = @($allEntitiesForSet | ForEach-Object { $_.$a } | Where-Object { $_ -and $_ -ne '-' } | Sort-Object -Unique) + $options = ($uniqueVals | ForEach-Object { "" }) -join '' + $filterDropdowns += "`n " + } + $filterDropdowns += "`n " + + $panelContent += @" +
    + $filterDropdowns +
    +"@ + + # Single combined table: Type | Name | Identifier | attributes... + $tableCounter++ + $tableId = "table_${tableCounter}" + $toggleId = "toggle_${tableCounter}" + + $combinedHeaders = " Type`n Name`n Identifier" + foreach ($a in $attrNames) { $combinedHeaders += "`n $a" } + + $combinedRows = '' + # Users + foreach ($u in $sd.Users) { + $combinedRows += " `n" + $combinedRows += " User`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($u.DisplayName))`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($u.Identifier))`n" + foreach ($a in $attrNames) { + $val = $u.$a + $display = if ($val -ne '-') { [System.Net.WebUtility]::HtmlEncode($val) } else { '-' } + $combinedRows += " $display`n" + } + $combinedRows += " `n" + } + # Devices + foreach ($d in $sd.Devices) { + $combinedRows += " `n" + $combinedRows += " Device`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($d.DisplayName))`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($d.Identifier))`n" + foreach ($a in $attrNames) { + $val = $d.$a + $display = if ($val -ne '-') { [System.Net.WebUtility]::HtmlEncode($val) } else { '-' } + $combinedRows += " $display`n" + } + $combinedRows += " `n" + } + # Apps + foreach ($app in $sd.Apps) { + $combinedRows += " `n" + $combinedRows += " Enterprise App`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($app.DisplayName))`n" + $combinedRows += " $([System.Net.WebUtility]::HtmlEncode($app.Identifier))`n" + foreach ($a in $attrNames) { + $val = $app.$a + $display = if ($val -ne '-') { [System.Net.WebUtility]::HtmlEncode($val) } else { '-' } + $combinedRows += " $display`n" + } + $combinedRows += " `n" + } + + $totalCount = $sd.Users.Count + $sd.Devices.Count + $sd.Apps.Count + $panelContent += @" +
    +
    + $setName ($totalCount) +
    + + Show all +
    +
    +
    + + +$combinedHeaders + + +$combinedRows
    +"@ + $setScriptHtml += " var t$tableCounter = initRKTable('#$tableId');`n" + $setScriptHtml += " `$('#$toggleId').on('change', function() { t$tableCounter.page.len(`$(this).is(':checked') ? -1 : 10).draw(); });`n" + + $panelContent += "
    `n" + $setPanelsHtml += $panelContent + } + + # ===== Assemble body content ===== + $bodyContentHtml = @" + +
    +$tabButtonsHtml
    +$overviewPanelHtml +$setPanelsHtml + "@ - # Generate final HTML using shared template - $htmlContent = New-RKSolutionsReportTemplate ` - -TenantName $tenantName ` - -ReportTitle 'Security Attributes' ` + # Custom CSS + $customCss = @" + .rk-check { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 6px; background: rgba(22,163,74,0.1); color: var(--success); font-size: 0.85rem; font-weight: 700; } + .rk-dash { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 6px; background: rgba(163,163,163,0.08); color: var(--text-dim); font-size: 0.8rem; } + [data-theme="dark"] .rk-check { background: rgba(74,222,128,0.1); } + [data-theme="dark"] .rk-dash { background: rgba(82,82,82,0.15); } + .rk-mono { font-family: 'Geist Mono', ui-monospace, monospace; font-size: 0.82rem; } + .table tbody td, .table thead th { word-wrap: break-word; overflow-wrap: break-word; max-width: 300px; } + .rk-filter-bar .form-select { font-family: 'Geist', -apple-system, sans-serif; font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; } + + /* Mini Stats */ + .rk-mini-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; } + .rk-mini-stat { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 16px; text-align: center; } + .rk-mini-number { font-family: 'Geist', sans-serif; font-size: 1.6rem; font-weight: 700; color: var(--text); } + .rk-mini-label { font-family: 'Geist Mono', monospace; font-size: 0.62rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 2px; } + + /* Coverage Bars */ + .rk-cov-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; } + .rk-cov-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 16px 20px; } + .rk-cov-title { font-family: 'Geist Mono', monospace; font-size: 0.68rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 12px; } + .rk-cov-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } + .rk-cov-row:last-child { margin-bottom: 0; } + .rk-cov-name { font-size: 0.78rem; color: var(--text-body); min-width: 130px; } + .rk-cov-track { flex: 1; height: 8px; background: var(--bg-warm); border-radius: 4px; overflow: hidden; } + .rk-cov-fill { height: 100%; border-radius: 4px; } + .rk-cov-orange { background: linear-gradient(90deg, #ea580c, #fb923c); } + .rk-cov-blue { background: linear-gradient(90deg, #0284c7, #38bdf8); } + .rk-cov-green { background: linear-gradient(90deg, #16a34a, #4ade80); } + .rk-cov-purple { background: linear-gradient(90deg, #9333ea, #c084fc); } + .rk-cov-red { background: linear-gradient(90deg, #dc2626, #f87171); } + .rk-cov-pct { font-family: 'Geist Mono', monospace; font-size: 0.72rem; color: var(--text-muted); min-width: 36px; text-align: right; } +"@ + + # Build tags from attribute set names + $tags = @('Entra ID', 'Security') + $sets + + # Generate final HTML + $htmlContent = Get-RKSolutionsReportTemplate ` + -TenantName $TenantName ` + -ReportTitle 'Custom Security Attributes' ` -ReportSlug 'custom-security-attributes' ` -Eyebrow 'CUSTOM SECURITY ATTRIBUTES' ` - -Lede "Users with custom security attribute assignments across the $AttributeSet attribute set." ` + -Lede 'Custom security attribute assignments across users, devices, and enterprise applications.' ` -StatsCardsHtml $statsCardsHtml ` -BodyContentHtml $bodyContentHtml ` + -CustomCss $customCss ` -ReportDate $reportDate ` - -Tags @($AttributeSet, 'Entra ID', 'Security') + -Tags $tags $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 - Write-Host "HTML report saved to: $ExportPath" -ForegroundColor Green - if (-not $SendEmail) { - if ($IsWindows -or (-not (Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue))) { - Invoke-Item $ExportPath - } else { - & open $ExportPath - } - } + $script:ExportPath = $ExportPath + Write-Host "Report saved to: $ExportPath" -ForegroundColor Cyan + + try { Invoke-Item $ExportPath -ErrorAction Stop } + catch { Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow } return $ExportPath } diff --git a/module/Private/EntraAdminRoles.ps1 b/module/Private/EntraAdminRoles.ps1 index d1e533b..609bf96 100644 --- a/module/Private/EntraAdminRoles.ps1 +++ b/module/Private/EntraAdminRoles.ps1 @@ -62,15 +62,15 @@ function New-AdminRoleHTMLReport { $allRolesRows += @" - $($item.Principal) - $($item.DisplayName) - $($item.PrincipalType) - $($item.AccountStatus) - $($item.'Assigned Role') - $($item.AssignedRoleScopeName) + $([System.Net.WebUtility]::HtmlEncode($item.Principal)) + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrincipalType)) + $([System.Net.WebUtility]::HtmlEncode($item.AccountStatus)) + $([System.Net.WebUtility]::HtmlEncode($item.'Assigned Role')) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedRoleScopeName)) $assignmentTypeBadge - $($item.AssignmentStartDate) - $($item.AssignmentEndDate) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentStartDate)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentEndDate)) "@ } @@ -87,15 +87,15 @@ function New-AdminRoleHTMLReport { $userRolesRows += @" - $($item.Principal) - $($item.DisplayName) - $($item.PrincipalType) - $($item.AccountStatus) - $($item.'Assigned Role') - $($item.AssignedRoleScopeName) + $([System.Net.WebUtility]::HtmlEncode($item.Principal)) + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrincipalType)) + $([System.Net.WebUtility]::HtmlEncode($item.AccountStatus)) + $([System.Net.WebUtility]::HtmlEncode($item.'Assigned Role')) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedRoleScopeName)) $assignmentTypeBadge - $($item.AssignmentStartDate) - $($item.AssignmentEndDate) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentStartDate)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentEndDate)) "@ } @@ -121,7 +121,7 @@ function New-AdminRoleHTMLReport { if ($item.ActivatedMembers -and @($item.ActivatedMembers).Count -gt 0) { $activatedList = @() foreach ($activatedMember in $item.ActivatedMembers) { - $activatedList += "$($activatedMember.UserPrincipalName)" + $activatedList += [System.Net.WebUtility]::HtmlEncode($activatedMember.UserPrincipalName) } $activatedMembersText = $activatedList -join "
    " } @@ -131,16 +131,16 @@ function New-AdminRoleHTMLReport { $groupRolesRows += @" - $($item.Principal) - $($item.DisplayName) - $($item.PrincipalType) - $($item.AccountStatus) - $($item.'Assigned Role') - $($item.AssignedRoleScopeName) + $([System.Net.WebUtility]::HtmlEncode($item.Principal)) + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrincipalType)) + $([System.Net.WebUtility]::HtmlEncode($item.AccountStatus)) + $([System.Net.WebUtility]::HtmlEncode($item.'Assigned Role')) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedRoleScopeName)) $assignmentTypeBadge - $($item.AssignmentStartDate) - $($item.AssignmentEndDate) - $groupMembers + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentStartDate)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentEndDate)) + $([System.Net.WebUtility]::HtmlEncode($groupMembers)) $activatedMembersText "@ @@ -158,15 +158,15 @@ function New-AdminRoleHTMLReport { $spRolesRows += @" - $($item.Principal) - $($item.DisplayName) - $($item.PrincipalType) - $($item.AccountStatus) - $($item.'Assigned Role') - $($item.AssignedRoleScopeName) + $([System.Net.WebUtility]::HtmlEncode($item.Principal)) + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrincipalType)) + $([System.Net.WebUtility]::HtmlEncode($item.AccountStatus)) + $([System.Net.WebUtility]::HtmlEncode($item.'Assigned Role')) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedRoleScopeName)) $assignmentTypeBadge - $($item.AssignmentStartDate) - $($item.AssignmentEndDate) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentStartDate)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignmentEndDate)) "@ } @@ -183,16 +183,16 @@ function New-AdminRoleHTMLReport { $pimAuditLogsRows += @" - $($log.DateTime) - $($log.InitiatedBy) - $($log.OperationType) - $($log.InitiatedByType) - $($log.Role) - $($log.Target) - $($log.Operation) + $([System.Net.WebUtility]::HtmlEncode($log.DateTime)) + $([System.Net.WebUtility]::HtmlEncode($log.InitiatedBy)) + $([System.Net.WebUtility]::HtmlEncode($log.OperationType)) + $([System.Net.WebUtility]::HtmlEncode($log.InitiatedByType)) + $([System.Net.WebUtility]::HtmlEncode($log.Role)) + $([System.Net.WebUtility]::HtmlEncode($log.Target)) + $([System.Net.WebUtility]::HtmlEncode($log.Operation)) $resultBadge - $($log.RoleProperties) - $($log.Justification) + $([System.Net.WebUtility]::HtmlEncode($log.RoleProperties)) + $([System.Net.WebUtility]::HtmlEncode($log.Justification)) "@ } @@ -469,19 +469,19 @@ $tabButtons
    - +
    - - - - - - - - - - + + + + + + + + + + @@ -886,15 +886,29 @@ $tabButtons /* Filter bar form controls */ .rk-filter-bar .form-select, .rk-filter-bar .form-control { - font-family: 'JetBrains Mono', monospace; + font-family: 'Geist Mono', ui-monospace, monospace; font-size: 0.75rem; padding: 4px 8px; border-radius: 6px; } + + /* PIM Audit Logs: constrain long-content columns */ + #pimAuditLogsTable td { + word-break: break-word; + overflow-wrap: break-word; + vertical-align: top; + } + #pimAuditLogsTable td:nth-child(9), + #pimAuditLogsTable td:nth-child(10) { + font-size: 0.7rem; + max-height: 6em; + overflow: hidden; + text-overflow: ellipsis; + } "@ # Generate the full HTML report using the shared template - $htmlContent = New-RKSolutionsReportTemplate ` + $htmlContent = Get-RKSolutionsReportTemplate ` -TenantName $TenantName ` -ReportTitle 'Admin Roles' ` -ReportSlug 'entra-admin-roles' ` @@ -1067,8 +1081,7 @@ Function Get-PIMAuditLogs { } elseif ($oldValue -eq $newValue) { $roleProperties += "$($propName): $newValue" } else { - # Use HTML arrow entity instead of Unicode character - $roleProperties += "$($propName): $oldValue → $newValue" + $roleProperties += "$($propName): $oldValue → $newValue" } } } diff --git a/module/Private/Export-Results.ps1 b/module/Private/Export-Results.ps1 index ab04f5c..344aa4a 100644 --- a/module/Private/Export-Results.ps1 +++ b/module/Private/Export-Results.ps1 @@ -22,32 +22,6 @@ function Export-Results { Write-Host "OSVersion: $($osInfo.Platform), $($osInfo.Version)" -ForegroundColor Green } - $detectedWindows = $false - $detectedMacOS = $false - $detectedLinux = $false - if (Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue) { - $detectedWindows = $IsWindows - $detectedMacOS = $IsMacOS - $detectedLinux = $IsLinux - } else { - $osInfo = [System.Environment]::OSVersion.Platform - switch ($osInfo) { - 'Win32NT' { $detectedWindows = $true } - 'Unix' { - if (Test-Path '/System/Library/CoreServices/SystemVersion.plist') { $detectedMacOS = $true } - else { $detectedLinux = $true } - } - default { - try { - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { $detectedWindows = $true } - elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { $detectedMacOS = $true } - elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { $detectedLinux = $true } - else { $detectedWindows = $true } - } catch { $detectedWindows = $true } - } - } - } - $documentsPath = if ($OutputFolder -and (Test-Path $OutputFolder)) { $OutputFolder } else { diff --git a/module/Private/New-RKSolutionsReportTemplate.ps1 b/module/Private/Get-RKSolutionsReportTemplate.ps1 similarity index 65% rename from module/Private/New-RKSolutionsReportTemplate.ps1 rename to module/Private/Get-RKSolutionsReportTemplate.ps1 index 8db80dc..d5105db 100644 --- a/module/Private/New-RKSolutionsReportTemplate.ps1 +++ b/module/Private/Get-RKSolutionsReportTemplate.ps1 @@ -1,4 +1,4 @@ -function New-RKSolutionsReportTemplate { +function Get-RKSolutionsReportTemplate { param( [Parameter(Mandatory = $true)] [string]$TenantName, @@ -28,7 +28,10 @@ function New-RKSolutionsReportTemplate { [string]$ReportDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), [Parameter(Mandatory = $false)] - [string[]]$Tags = @() + [string[]]$Tags = @(), + + [Parameter(Mandatory = $false)] + [string]$StatsClass = '' ) # Build tag pills HTML @@ -50,8 +53,9 @@ function New-RKSolutionsReportTemplate { # Build stats section $statsHtml = '' if ($StatsCardsHtml) { + $statsClasses = if ($StatsClass) { "rk-stats $StatsClass" } else { "rk-stats" } $statsHtml = @" -
    +
    $StatsCardsHtml
    "@ @@ -66,7 +70,7 @@ $StatsCardsHtml $TenantName - $Eyebrow - + @@ -85,94 +89,118 @@ $StatsCardsHtml
    Date/TimeInitiated ByOperation TypeInitiator TypeRoleTargetOperationResultRole PropertiesJustificationDate/TimeInitiated ByOperation TypeInitiator TypeRoleTargetOperationResultRole PropertiesJustification