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/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 ` - - - -
-
- -
- -
- -
-
- -
-
-
- -

$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
-
-
-
-
- - - - - -'@ - # 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) - $($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)) "@ } @@ -2135,23 +79,23 @@ 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) - $($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)) "@ } @@ -2160,43 +104,43 @@ 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) { $activatedList = @() foreach ($activatedMember in $item.ActivatedMembers) { - $activatedList += "$($activatedMember.UserPrincipalName)" + $activatedList += [System.Net.WebUtility]::HtmlEncode($activatedMember.UserPrincipalName) } $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) - $($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 "@ @@ -2206,23 +150,23 @@ 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) - $($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)) "@ } @@ -2232,140 +176,749 @@ 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) - $($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)) "@ } } - # 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 = @' - -'@ + } - # Insert the dark mode pagination CSS before the tag - $htmlContent = $htmlContent.Replace("", "$darkModePaginationCss`n") + /* Filter bar form controls */ + .rk-filter-bar .form-select, + .rk-filter-bar .form-control { + 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 = Get-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 +940,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 +956,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 +985,7 @@ function Get-SecurityGroups { } } } - + # Store the members in the hashtable $securityGroupMembers[$group.id] = @{ GroupDisplayName = $group.displayName @@ -2440,13 +993,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 +1007,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 +1031,7 @@ Function Get-PIMAuditLogs { $initiatedByUser = "Unknown" } } - + # Determine initiator type $initiatorType = "Unknown" if ($PIMaudit.InitiatedBy.user) { @@ -2488,12 +1041,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 +1054,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 +1066,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" @@ -2528,53 +1081,52 @@ 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" } } } $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 +1139,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 +1167,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 +1185,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 +1202,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 +1243,7 @@ function Get-GroupActivationDetails { } } } - + return $enrichedActivations } @@ -2755,12 +1307,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 +1325,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 +1339,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 +1369,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 +1383,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 +1403,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 +1417,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 +1448,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 +1471,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 +1494,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 +1514,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 +1608,7 @@ function Invoke-EntraAdminRolesReportCore { $startTime = $null } } - + if ($activation.endDateTime) { try { # Try different parsing approaches @@ -3075,8 +1627,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 +1636,7 @@ function Invoke-EntraAdminRolesReportCore { "$($timeSpan.Hours) hours, $($timeSpan.Minutes) minutes" } } else { "N/A" } - + $activatedMembers += [PSCustomObject]@{ UserPrincipalName = $targetUser.UserPrincipalName DisplayName = $targetUser.DisplayName @@ -3108,83 +1660,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 +1759,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 +1799,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 +1810,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 +1821,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 +1835,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 +1847,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 +1886,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 +1894,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 +1913,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 +1930,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 +1945,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 +1954,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 +1967,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 +2000,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 +2011,7 @@ function Invoke-EntraAdminRolesReportCore { }) } $newCount = $report.Count - + if ($DebugMode) { Write-Host "debug: Removed $($originalCount - $newCount) user assignments from report (activated PIM roles)" -ForegroundColor Green } } 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/Get-RKSolutionsReportTemplate.ps1 b/module/Private/Get-RKSolutionsReportTemplate.ps1 new file mode 100644 index 0000000..d5105db --- /dev/null +++ b/module/Private/Get-RKSolutionsReportTemplate.ps1 @@ -0,0 +1,931 @@ +function Get-RKSolutionsReportTemplate { + param( + [Parameter(Mandatory = $true)] + [string]$TenantName, + + [Parameter(Mandatory = $true)] + [string]$ReportTitle, + + [Parameter(Mandatory = $true)] + [string]$ReportSlug, + + [Parameter(Mandatory = $true)] + [string]$Eyebrow, + + [Parameter(Mandatory = $false)] + [string]$Lede, + + [Parameter(Mandatory = $false)] + [string]$StatsCardsHtml = '', + + [Parameter(Mandatory = $true)] + [string]$BodyContentHtml, + + [Parameter(Mandatory = $false)] + [string]$CustomCss = '', + + [Parameter(Mandatory = $false)] + [string]$ReportDate = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), + + [Parameter(Mandatory = $false)] + [string[]]$Tags = @(), + + [Parameter(Mandatory = $false)] + [string]$StatsClass = '' + ) + + # Build tag pills HTML + $tagPillsHtml = '' + if ($Tags.Count -gt 0) { + $tagPillsHtml = '
' + foreach ($tag in $Tags) { + $tagPillsHtml += "$tag" + } + $tagPillsHtml += '
' + } + + # Build lede HTML + $ledeHtml = '' + if ($Lede) { + $ledeHtml = "

$Lede

" + } + + # Build stats section + $statsHtml = '' + if ($StatsCardsHtml) { + $statsClasses = if ($StatsClass) { "rk-stats $StatsClass" } else { "rk-stats" } + $statsHtml = @" +
+$StatsCardsHtml +
+"@ + } + + $html = @" + + + + + +$TenantName - $Eyebrow + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ +
+ cd ./reports/$ReportSlug +
+ + +
$Eyebrow · GENERATED $ReportDate
+ + +

$TenantName $ReportTitle Report

+ + + $ledeHtml + + + $tagPillsHtml + + +
+ + + $statsHtml + + + $BodyContentHtml + +
+ + + + + + + + +"@ + + return $html +} diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 24e6bae..d266a1e 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)] @@ -16,7 +16,7 @@ function New-IntuneAnomaliesHTMLReport { [Parameter(Mandatory = $false)] [array]$Report_InactiveDevices, [Parameter(Mandatory = $false)] - [array]$Report_OperationSystemEdtionOverview, + [array]$Report_OperatingSystemEditionOverview, [Parameter(Mandatory = $false)] [array]$Report_NoncompliantDevices, [Parameter(Mandatory = $false)] @@ -27,7 +27,8 @@ function New-IntuneAnomaliesHTMLReport { # Default ExportPath to current folder if not provided if (-not $ExportPath) { - $ExportPath = Join-Path (Get-Location).Path "$TenantName-IntuneAnomaliesReport.html" + $safeTenantName = $TenantName -replace '[\\/:*?"<>|]', '_' + $ExportPath = Join-Path (Get-Location).Path "$safeTenantName-IntuneAnomaliesReport.html" } # Calculate counts for dashboard statistics @@ -37,1946 +38,21 @@ function New-IntuneAnomaliesHTMLReport { $Report_DevicesWithoutAutopilotHash_Count = $Report_DevicesWithoutAutopilotHash | Measure-Object | Select-Object -ExpandProperty Count $Report_InactiveDevices_Count = $Report_InactiveDevices | Measure-Object | Select-Object -ExpandProperty Count $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_OperatingSystemEditionOverview_Count = $Report_OperatingSystemEditionOverview | 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) { $applicationFailureRows += @" - $($item.Customer) - $($item.Application) - $($item.Platform) - $($item.Version) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.Application)) + $([System.Net.WebUtility]::HtmlEncode($item.Platform)) + $([System.Net.WebUtility]::HtmlEncode($item.Version)) $($item.FailedDeviceCount) $($item.FailedDevicePercentage)% @@ -1988,12 +64,12 @@ table.dataTable tfoot td { foreach ($item in $Report_DevicesWithMultipleUsers) { $multipleUsersRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.EnrollmentProfile) - $($item.usersLoggedOnCount) - $($item.usersLoggedOnIds) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.EnrollmentProfile)) + $($item.usersLoggedOnCount) + $([System.Net.WebUtility]::HtmlEncode($item.usersLoggedOnIds)) "@ } @@ -2003,12 +79,12 @@ table.dataTable tfoot td { foreach ($item in $Report_NotEncryptedDevices) { $notEncryptedRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.Serialnumber) - $($item.DeviceManufacturer) - $($item.DeviceModel) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) "@ } @@ -2018,12 +94,12 @@ table.dataTable tfoot td { foreach ($item in $Report_DevicesWithoutAutopilotHash) { $noAutopilotHashRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.Serialnumber) - $($item.DeviceManufacturer) - $($item.DeviceModel) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) "@ } @@ -2033,13 +109,13 @@ table.dataTable tfoot td { foreach ($item in $Report_InactiveDevices) { $inactiveDevicesRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.Serialnumber) - $($item.DeviceManufacturer) - $($item.DeviceModel) - $($item.LastContact) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) + $([System.Net.WebUtility]::HtmlEncode($item.LastContact)) "@ } @@ -2047,33 +123,33 @@ table.dataTable tfoot td { # Generate table rows for noncompliant devices $noncompliantDevicesRows = "" foreach ($item in $Report_NoncompliantDevices) { - $statusBadge = 'Noncompliant' - + $statusBadge = 'Noncompliant' + $noncompliantDevicesRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.Serialnumber) - $($item.DeviceManufacturer) - $($item.DeviceModel) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) $statusBadge - $($item.NoncompliantBasedOn) - $($item.NoncompliantAlert) + $([System.Net.WebUtility]::HtmlEncode($item.NoncompliantBasedOn)) + $([System.Net.WebUtility]::HtmlEncode($item.NoncompliantAlert)) "@ } # Generate table rows for OS Edition Overview $osEditionOverviewRows = "" - foreach ($item in $Report_OperationSystemEdtionOverview) { + foreach ($item in $Report_OperatingSystemEditionOverview) { $osEditionOverviewRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.OperatingSystemEdition) - $($item.OSFriendlyname) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.OperatingSystemEdition)) + $([System.Net.WebUtility]::HtmlEncode($item.OSFriendlyname)) "@ } @@ -2083,36 +159,892 @@ table.dataTable tfoot td { foreach ($item in $Report_DisabledPrimaryUsers) { $disabledPrimaryUsersRows += @" - $($item.Customer) - $($item.DeviceName) - $($item.PrimaryUser) - $($item.Serialnumber) - $($item.DeviceManufacturer) - $($item.DeviceModel) + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) "@ } - # 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, each with a unique color, 5-column grid) + $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_OperatingSystemEditionOverview_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: 'Geist Mono', ui-monospace, monospace; + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 6px; + } +"@ + + # Generate the full HTML report using the shared template + $htmlContent = Get-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') ` + -StatsClass 'rk-stats-5' # Export to HTML file $htmlContent | Out-File -FilePath $ExportPath -Encoding utf8 @@ -2125,7 +1057,8 @@ table.dataTable tfoot td { # Open the HTML file only if we're not sending email if (-not $SendEmail) { - Invoke-Item $ExportPath + try { Invoke-Item $ExportPath -ErrorAction Stop } + catch { Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow } } } @@ -2136,7 +1069,7 @@ function Get-AllDeviceData { $Customer ) - @{ + @{ "0" = "unknown" "4" = "Windows 10/11 Enterprise" "27" = "Windows 10/11 Enterprise N" @@ -2166,41 +1099,42 @@ function Get-AllDeviceData { "^10\.0\.22631" { return "Windows 11 23H2" } "^10\.0\.22635" { return "Windows 11 23H2 Insider Preview" } "^10\.0\.261" { return "Windows 11 24H2" } + "^10\.0\.262" { return "Windows 11 25H2" } 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 @@ -2228,14 +1162,15 @@ function Get-AllDeviceData { 'ComplianceState', 'usersLoggedOn', # Contains userId for logged-on users 'hardwareInformation', # Contains nested properties like tpmVersion, OS details, BiosVersion - 'managementAgent' # Indicates the management agent used (e.g., Intune) + 'managementAgent', # Indicates the management agent used (e.g., Intune) + 'skuFamily' # OS edition (Pro, Enterprise, Home, etc.) - more reliable than hardwareInformation.operatingSystemEdition ) # Get all Windows Devices from Microsoft Intune $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 +1190,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 +1221,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 +1239,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 +1261,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,14 +1298,14 @@ 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 OperatingSystemVersion = $DeviceProperties.OSVersion OSFriendlyname = Get-OSFriendlyName -OperatingSystemVersion $DeviceProperties.OSVersion OperatingSystemLanguage = if ($hardwareInfo.operatingSystemLanguage) { $hardwareInfo.operatingSystemLanguage } else { "Unknown" } - OperatingSystemEdition = if ($hardwareInfo.operatingSystemEdition) { $hardwareInfo.operatingSystemEdition } else { "Unknown" } + OperatingSystemEdition = if ($DeviceProperties.skuFamily) { $DeviceProperties.skuFamily } elseif ($hardwareInfo.operatingSystemEdition) { $hardwareInfo.operatingSystemEdition } else { "Unknown" } operatingSystemProductType = if ($hardwareInfo.operatingSystemProductType) { Get-OperatingSystemProductType -Customer "$($hardwareInfo.operatingSystemProductType)" } else { "Unknown" } BiosVersion = if ($hardwareInfo.systemManagementBIOSVersion) { $hardwareInfo.systemManagementBIOSVersion } else { "Unknown" } ComplianceStatus = $DeviceProperties.ComplianceState @@ -2389,14 +1324,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 +1343,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,25 +1370,12 @@ function Get-ApplicationFailures { } } } - - # Set temporary directory and file path based on OS - if ($detectedWindows) { - $tempDir = "C:\temp" - } else { - # 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 - } + + # Use a unique temporary file to avoid race conditions + $Data = [System.IO.Path]::GetTempFileName() $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" @@ -2478,15 +1400,15 @@ function Get-ApplicationFailures { # Fix char encoding to UTF-8 $Response = [system.Text.Encoding]::UTF8.GetString(($DataFile).ToCharArray()) | ConvertFrom-Json - #Maak een nieuwe array + # Build result array from response values $ReturnObject = New-Object System.Collections.ArrayList - #voor elke waardeverzameling in values + # For each value set in the response foreach ($value in $Response.Values) { - #maak een nieuw lineobject[hashtable] + # Create a new line object (hashtable) $LineObject = @{ } - #voor elke prop in het het schema + # For each property in the schema foreach ($prop in $Response.Schema) { $LineObject[$prop.Column] = $value[$Response.Schema.IndexOf($prop)] } @@ -2497,18 +1419,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 +1445,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 } - diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index b8936d8..0db5aeb 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -62,7 +62,7 @@ function Get-GroupMemberTargetType { elseif ($odataType -eq '#microsoft.graph.user') { $result = 'User' } } } - catch { } + catch { Write-Verbose "Get-GroupMemberTargetType: Failed to query members for group $GroupId -- $_" } } $script:GroupMemberTypeCache[$GroupId] = $result return $result @@ -114,7 +114,10 @@ function Get-AutopilotProfileConfigByDisplayName { Locale = if ($autopilotProfile.locale) { $autopilotProfile.locale } else { "-" } } } - catch { return $null } + catch { + Write-Verbose "Get-AutopilotProfileConfigByDisplayName: Failed for '$DisplayName' -- $_" + return $null + } } function Get-MobileAppDisplayName { @@ -180,7 +183,10 @@ function Get-EspConfigByDisplayName { SelectedMobileAppIds = $selectedIds } } - catch { return $null } + catch { + Write-Verbose "Get-EspConfigByDisplayName: Failed for '$DisplayName' -- $_" + return $null + } } function Get-GroupParentGroupNames { @@ -1675,317 +1681,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 = Get-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" } @@ -2063,8 +1897,8 @@ function Get-ArchitectureDiagramFragment { foreach ($gd in $DeviceGroupDetails) { $encName = [System.Net.WebUtility]::HtmlEncode($gd.DisplayName) $encType = [System.Net.WebUtility]::HtmlEncode($gd.GroupType) - $ruleCell = if ($gd.MembershipRule) { $gd.MembershipRule } else { "-" } - $groupTableRows += "$encName$encType$ruleCell" + $ruleCell = if ($gd.MembershipRule -and $gd.MembershipRule -ne "-") { '' + $gd.MembershipRule + '' } else { "-" } + $groupTableRows += "$encName$encType$ruleCell" } } $autopilotName = if ($autopilotPolicies.Count -gt 0) { [System.Net.WebUtility]::HtmlEncode($autopilotPolicies[0]) } else { "- None -" } @@ -2082,7 +1916,7 @@ function Get-ArchitectureDiagramFragment { "@ } [void]$sb.AppendLine('
') [void]$sb.AppendLine(@" -
+
Device
@@ -2094,7 +1928,7 @@ function Get-ArchitectureDiagramFragment { "@) [void]$sb.AppendLine('') [void]$sb.AppendLine(@" -
+
Entra ID Groups
@@ -2116,14 +1950,14 @@ function Get-ArchitectureDiagramFragment { [void]$sb.AppendLine('') } [void]$sb.AppendLine(@" -
+
-
+
Autopilot Profile
$autopilotName
-
+
Enrollment Status Page
$espName
@@ -2339,7 +2173,8 @@ function Get-AppliedFlowHtmlFragment { $gEnc = [System.Net.WebUtility]::HtmlEncode($gtr[0]) $tEnc = [System.Net.WebUtility]::HtmlEncode($gtr[1]) $rEnc = [System.Net.WebUtility]::HtmlEncode($gtr[2]) - [void]$sb.AppendLine('
' + $pEnc + '
' + $gEnc + '
' + $tEnc + '
' + $rEnc + '
') + $rDisplay = if ($rEnc -and $rEnc -ne '-' -and $rEnc -ne 'No Filter') { '' + $rEnc + '' } else { $rEnc } + [void]$sb.AppendLine('
' + $pEnc + '
' + $gEnc + '
' + $tEnc + '
' + $rDisplay + '
') } [void]$sb.AppendLine('
') $sb.ToString() @@ -2516,11 +2351,12 @@ $espRestHtml $gEnc = [System.Net.WebUtility]::HtmlEncode($gtr[0]) $tEnc = [System.Net.WebUtility]::HtmlEncode($gtr[1]) $rEnc = [System.Net.WebUtility]::HtmlEncode($gtr[2]) + $rDisplay = if ($rEnc -and $rEnc -ne '-' -and $rEnc -ne 'No Filter') { '' + $rEnc + '' } else { $rEnc } $cloudPCProvisioningDeviceGroupsHtml += '
' $cloudPCProvisioningDeviceGroupsHtml += '
Policy name
' + $pEnc + '
' $cloudPCProvisioningDeviceGroupsHtml += '
Group name
' + $gEnc + '
' $cloudPCProvisioningDeviceGroupsHtml += '
Type
' + $tEnc + '
' - $cloudPCProvisioningDeviceGroupsHtml += '
Membership rule
' + $rEnc + '
' + $cloudPCProvisioningDeviceGroupsHtml += '
Membership rule
' + $rDisplay + '
' $cloudPCProvisioningDeviceGroupsHtml += '
' } $cloudPCProvisioningDeviceGroupsHtml = $cloudPCTableHeader + $cloudPCProvisioningDeviceGroupsHtml @@ -2582,11 +2418,12 @@ $espRestHtml $gEnc = [System.Net.WebUtility]::HtmlEncode($gtr[0]) $tEnc = [System.Net.WebUtility]::HtmlEncode($gtr[1]) $rEnc = [System.Net.WebUtility]::HtmlEncode($gtr[2]) + $rDisplay = if ($rEnc -and $rEnc -ne '-' -and $rEnc -ne 'No Filter') { '' + $rEnc + '' } else { $rEnc } $cloudPCUserSettingsDeviceGroupsHtml += '
' $cloudPCUserSettingsDeviceGroupsHtml += '
Policy name
' + $pEnc + '
' $cloudPCUserSettingsDeviceGroupsHtml += '
Group name
' + $gEnc + '
' $cloudPCUserSettingsDeviceGroupsHtml += '
Type
' + $tEnc + '
' - $cloudPCUserSettingsDeviceGroupsHtml += '
Membership rule
' + $rEnc + '
' + $cloudPCUserSettingsDeviceGroupsHtml += '
Membership rule
' + $rDisplay + '
' $cloudPCUserSettingsDeviceGroupsHtml += '
' } $cloudPCUserSettingsDeviceGroupsHtml = $cloudPCUserSettingsTableHeader + $cloudPCUserSettingsDeviceGroupsHtml @@ -2632,7 +2469,7 @@ $userTable foreach ($gd in $DeviceGroupDetails) { $name = if ($gd.DisplayName) { $gd.DisplayName } else { "-" } $gType = if ($gd.GroupType) { $gd.GroupType } else { "Static" } - $ruleHtml = if ($gd.MembershipRule) { $gd.MembershipRule } else { "-" } + $ruleHtml = if ($gd.MembershipRule -and $gd.MembershipRule -ne "-") { '' + $gd.MembershipRule + '' } else { "-" } $encName = [System.Net.WebUtility]::HtmlEncode($name) $encType = [System.Net.WebUtility]::HtmlEncode($gType) $entraGroupListHtml += '
' @@ -2732,224 +2569,279 @@ 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:'Geist',-apple-system,sans-serif;font-size:1.75rem;font-weight:800;margin-bottom:0.5rem;color:var(--text);letter-spacing:-0.02em;} +.overview-header p{font-family:'Geist',-apple-system,sans-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:'Geist',-apple-system,sans-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:'Geist Mono',ui-monospace,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:'Geist Mono',ui-monospace,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:'Geist Mono',ui-monospace,monospace;} +.assignment-filters-modern .filter-dropdown-btn{min-width:140px;text-align:left;font-family:'Geist Mono',ui-monospace,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:'Geist Mono',ui-monospace,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 = Get-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 } diff --git a/module/Private/Invoke-GraphRequestWithPaging.ps1 b/module/Private/Invoke-GraphRequestWithPaging.ps1 index 1ed4ed1..ef87907 100644 --- a/module/Private/Invoke-GraphRequestWithPaging.ps1 +++ b/module/Private/Invoke-GraphRequestWithPaging.ps1 @@ -33,7 +33,10 @@ function Invoke-GraphRequestWithPaging { } } while (-not $success -and $retryCount -lt $MaxRetries) if (-not $success) { break } - if ($results.Count -gt 10000) { break } + if ($results.Count -gt 10000) { + Write-Warning "Invoke-GraphRequestWithPaging: Results truncated at 10,000 items for URI: $Uri" + break + } } while ($currentUri) $results.ToArray() } diff --git a/module/Private/M365License.ps1 b/module/Private/M365License.ps1 index f0e9a82..564192f 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,1495 +31,30 @@ 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' } - } - + + $assignmentTypeText = [System.Net.WebUtility]::HtmlEncode($item.AssignmentType) + $accountStatus = if ($item.AccountEnabled -eq "Yes") { - 'Enabled' + 'Enabled' } else { - 'Disabled' + 'Disabled' } - + $tableRows += @" - $($item.DisplayName) - $($item.UserPrincipalName) + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.UserPrincipalName)) $accountStatus - $($item.LastSuccessfulSignIn) - $($item.AssignedLicensesFriendlyName) - $assignmentTypeBadge - $($item.Inheritance) + $([System.Net.WebUtility]::HtmlEncode($item.LastSuccessfulSignIn)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedLicensesFriendlyName)) + $assignmentTypeText + $([System.Net.WebUtility]::HtmlEncode($item.Inheritance)) "@ } @@ -1527,35 +62,35 @@ 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) - $($item.CreatedDate) - $($item.EndDate) + $([System.Net.WebUtility]::HtmlEncode($item.FriendlyName)) + $([System.Net.WebUtility]::HtmlEncode($item.CreatedDate)) + $([System.Net.WebUtility]::HtmlEncode($item.EndDate)) $licenseStatusBadge $($item.ConsumedUnits) $($item.TotalLicenses) @@ -1564,2087 +99,330 @@ 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) + # Generate table rows for disabled users (users with AccountEnabled = No that have licenses) + $disabledUsersRows = "" + $disabledUsers = $Report | Where-Object { $_.AccountEnabled -eq "No" } + foreach ($item in $disabledUsers) { + $assignmentTypeText = [System.Net.WebUtility]::HtmlEncode($item.AssignmentType) - # Add additional CSS for dark mode pagination - $darkModePaginationCss = @" - + $disabledUsersRows += @" + + $([System.Net.WebUtility]::HtmlEncode($item.DisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.UserPrincipalName)) + Disabled + $([System.Net.WebUtility]::HtmlEncode($item.LastSuccessfulSignIn)) + $([System.Net.WebUtility]::HtmlEncode($item.AssignedLicensesFriendlyName)) + $assignmentTypeText + $([System.Net.WebUtility]::HtmlEncode($item.Inheritance)) + "@ - - # 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 + # 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
    +
    +"@ - # Set script-scoped variable for email attachment - $script:ExportPath = $ExportPath + # Build body content HTML (tabs + panels + filter containers + tables + script) + $bodyContentHtml = @" + +
    + + + +
    - Write-Host "All actions completed successfully." -ForegroundColor Cyan - Write-Host "Report saved to: $ExportPath" -ForegroundColor Cyan + +
    +
    + Filters: + + + + +
    +
    +
    + License Assignment +
    + + Show all +
    +
    +
    + + + + + + + + + + + + + + $tableRows + +
    Display NameUser Principal NameAccount StatusLast Successful Sign InLicenseAssignment TypeInheritance Details
    +
    +
    +
    - # 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" - } - - # 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: 'Geist Mono', ui-monospace, 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 = Get-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 @@ -3730,7 +508,7 @@ function Invoke-M365LicenseReportCore { } # Create an overview of subscriptions with their end date - $SubscriptionOverview = @() + $SubscriptionOverview = [System.Collections.Generic.List[PSObject]]::new() if ($useCloudLicensingAPI) { # NEW: Process allotments from Cloud Licensing API @@ -3854,7 +632,7 @@ function Invoke-M365LicenseReportCore { $availableLicenses = $skuData.TotalLicenses - $skuData.ConsumedUnits - $SubscriptionOverview += [PSCustomObject]@{ + $SubscriptionOverview.Add([PSCustomObject]@{ SubscriptionId = ($skuData.SubscriptionIds | Select-Object -First 1) FriendlyName = $skuData.FriendlyName SKUPartNumber = $skuData.SKUPartNumber @@ -3865,7 +643,7 @@ function Invoke-M365LicenseReportCore { TotalLicenses = $skuData.TotalLicenses AvailableLicenses = $availableLicenses AssignableTo = $skuData.AssignableTo - } + }) } # Show summary of date matching @@ -3938,7 +716,7 @@ function Invoke-M365LicenseReportCore { $consumedUnits = if ($sku.ConsumedUnits) { $sku.ConsumedUnits } else { 0 } $availableLicenses = $totalLicenses - $consumedUnits - $SubscriptionOverview += [PSCustomObject]@{ + $SubscriptionOverview.Add([PSCustomObject]@{ SubscriptionId = $subscription.Id FriendlyName = $friendlyName CreatedDate = $formattedCreatedDate @@ -3947,7 +725,7 @@ function Invoke-M365LicenseReportCore { ConsumedUnits = $consumedUnits TotalLicenses = $totalLicenses AvailableLicenses = $availableLicenses - } + }) } } @@ -3976,8 +754,8 @@ function Invoke-M365LicenseReportCore { } } - # Initialize the report array - $Report = @() + # Initialize the report collection + $Report = [System.Collections.Generic.List[PSObject]]::new() # Process user license data $totalUsers = $users.Count @@ -3986,26 +764,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 +791,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 +811,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 +835,7 @@ function Invoke-M365LicenseReportCore { } $groupNames = $groupNamesList -join ", " } - + # Determine inheritance description if ($isDirect -and -not $groupNames) { $inheritance = "Direct" @@ -4077,7 +855,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,9 +869,9 @@ function Invoke-M365LicenseReportCore { IsDirect = $isDirect IsInherited = $isInherited } - + # Add to the report - $Report += $licenseData + $Report.Add($licenseData) } } @@ -4131,28 +909,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 diff --git a/module/Private/Send-EmailWithAttachment.ps1 b/module/Private/Send-EmailWithAttachment.ps1 index a70675e..06ed749 100644 --- a/module/Private/Send-EmailWithAttachment.ps1 +++ b/module/Private/Send-EmailWithAttachment.ps1 @@ -68,7 +68,14 @@ function Send-EmailWithAttachment { } $sendMailUri = 'https://graph.microsoft.com/v1.0/me/sendMail' - if ($From) { $sendMailUri = "https://graph.microsoft.com/v1.0/users/$From/sendMail" } + if ($From) { + if ($From -notmatch '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' -and + $From -notmatch '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { + Write-Error "Invalid -From value '$From'. Must be an email address or user object ID." + return $false + } + $sendMailUri = "https://graph.microsoft.com/v1.0/users/$From/sendMail" + } try { $jsonBody = ConvertTo-Json -InputObject $mailRequestBody -Depth 20 diff --git a/module/Public/Connect-RKGraph.ps1 b/module/Public/Connect-RKGraph.ps1 index 33bc7b0..5c1f460 100644 --- a/module/Public/Connect-RKGraph.ps1 +++ b/module/Public/Connect-RKGraph.ps1 @@ -31,7 +31,9 @@ function Connect-RKGraph { 'PrivilegedEligibilitySchedule.Read.AzureADGroup', 'Mail.Send', 'CloudLicensing.Read', - 'CloudPC.Read.All' + 'CloudPC.Read.All', + 'CustomSecAttributeAssignment.Read.All', + 'CustomSecAttributeDefinition.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..e39d7be --- /dev/null +++ b/module/Public/Get-CustomSecurityAttributesReport.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + Generates an interactive HTML report of custom security attributes across users, devices, and enterprise apps. + Connect first with Connect-RKGraph; this cmdlet uses the existing connection. +.DESCRIPTION + Queries Microsoft Graph for custom security attributes assigned to users, devices, and service principals. + Auto-discovers attribute sets or accepts a specific set. Generates an interactive report with: + - Overview tab with coverage matrix (which entities have which attribute sets) + - Dynamic tabs per attribute set with sub-sections for users, devices, and enterprise apps + - DataTables with search, export (Excel/CSV/PDF), column visibility, and dark/light theme toggle +.PARAMETER AttributeSet + Specific attribute set to report on. If not specified, auto-discovers and reports on all attribute sets. +.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 + Auto-discovers all attribute sets and generates a full report. +.EXAMPLE + Get-CustomSecurityAttributesReport -AttributeSet "ComplianceData" + Report on a specific attribute set only. +.EXAMPLE + Get-CustomSecurityAttributesReport -SendEmail -Recipient "admin@contoso.com" + Generate report and email it. +#> +function Get-CustomSecurityAttributesReport { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$AttributeSet, + + [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 across all entity types + $dataParams = @{ DebugMode = $DebugMode } + if ($AttributeSet) { $dataParams['AttributeSet'] = $AttributeSet } + $reportData = Get-CustomSecurityAttributeData @dataParams + + $totalEntities = $reportData.Counts.Users + $reportData.Counts.Devices + $reportData.Counts.Apps + if ($totalEntities -eq 0) { + Write-Host 'No entities found with custom security attributes.' -ForegroundColor Yellow + return + } + + Write-Host "Found $($reportData.Counts.Users) user(s), $($reportData.Counts.Devices) device(s), $($reportData.Counts.Apps) app(s) with attributes." -ForegroundColor Green + + # Generate HTML report + $htmlPath = New-CustomSecurityAttributesHTMLReport -TenantName $tenantName -ReportData $reportData -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/Public/Get-IntuneAnomaliesReport.ps1 b/module/Public/Get-IntuneAnomaliesReport.ps1 index d60a669..cb67841 100644 --- a/module/Public/Get-IntuneAnomaliesReport.ps1 +++ b/module/Public/Get-IntuneAnomaliesReport.ps1 @@ -31,7 +31,7 @@ try { $Report_ApplicationFailureReport = Get-ApplicationFailures $Report_DevicesWithMultipleUsers = $DeviceData | Where-Object { $_.usersLoggedOnCount -gt 1 -and $_.EnrollmentProfile -in $UserDrivenAutopilotProfiles.displayName } | Select-Object Customer, DeviceName, PrimaryUser, EnrollmentProfile, usersLoggedOnCount, usersLoggedOnIds - $Report_OperationSystemEdtionOverview = $DeviceData | Select-Object Customer, DeviceName, PrimaryUser, OperatingSystemEdition, OSFriendlyname + $Report_OperatingSystemEditionOverview = $DeviceData | Select-Object Customer, DeviceName, PrimaryUser, OperatingSystemEdition, OSFriendlyname $Report_NotEncryptedDevices = $DeviceData | Where-Object { $_.Encrypted -eq $false } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel $Report_DevicesWithoutAutopilotHash = $DeviceData | Where-Object { $_.DeviceHashUploaded -eq $false } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel $Report_InactiveDevices = $DeviceData | Where-Object { $_.LastContact -lt (Get-Date).AddDays(-90) } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact @@ -52,15 +52,16 @@ try { } } - New-IntuneAnomaliesHTMLReport -TenantName $tenantname -Report_ApplicationFailureReport $Report_ApplicationFailureReport -Report_DevicesWithMultipleUsers $Report_DevicesWithMultipleUsers -Report_NotEncryptedDevices $Report_NotEncryptedDevices -Report_DevicesWithoutAutopilotHash $Report_DevicesWithoutAutopilotHash -Report_InactiveDevices $Report_InactiveDevices -Report_NoncompliantDevices $Report_NoncompliantDevices -Report_OperationSystemEdtionOverview $Report_OperationSystemEdtionOverview -Report_DisabledPrimaryUsers $Report_DisabledPrimaryUsers -ExportPath $ExportPath + New-IntuneAnomaliesHTMLReport -TenantName $tenantname -Report_ApplicationFailureReport $Report_ApplicationFailureReport -Report_DevicesWithMultipleUsers $Report_DevicesWithMultipleUsers -Report_NotEncryptedDevices $Report_NotEncryptedDevices -Report_DevicesWithoutAutopilotHash $Report_DevicesWithoutAutopilotHash -Report_InactiveDevices $Report_InactiveDevices -Report_NoncompliantDevices $Report_NoncompliantDevices -Report_OperatingSystemEditionOverview $Report_OperatingSystemEditionOverview -Report_DisabledPrimaryUsers $Report_DisabledPrimaryUsers -ExportPath $ExportPath + $emailSent = $false if ($SendEmail -and $Recipient) { $subject = "$tenantname - Intune Anomalies Report" $bodyHtml = "

    Intune Anomalies Report

    Attached is the latest Intune anomalies 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 $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 } } - if ($SendEmail -and (Test-Path -Path $script:ExportPath)) { Remove-Item -Path $script:ExportPath -Force; Write-Host 'INFO: Temporary report file deleted.' -ForegroundColor Green } + if ($SendEmail -and $emailSent -and (Test-Path -Path $script:ExportPath)) { Remove-Item -Path $script:ExportPath -Force; Write-Host 'INFO: Temporary report file deleted.' -ForegroundColor Green } } catch { Write-Error "Error: $_"; throw $_ } finally { diff --git a/module/Public/Get-IntuneEnrollmentFlowsReport.ps1 b/module/Public/Get-IntuneEnrollmentFlowsReport.ps1 index a963354..652e331 100644 --- a/module/Public/Get-IntuneEnrollmentFlowsReport.ps1 +++ b/module/Public/Get-IntuneEnrollmentFlowsReport.ps1 @@ -177,9 +177,13 @@ try { Write-Host "Device visualization report saved: $generated" -ForegroundColor Green if ($MermaidOverview) { - $mmdPath = [System.IO.Path]::ChangeExtension($generated, '.mmd') - [System.IO.File]::WriteAllText($mmdPath, $mermaidDiagram, [System.Text.UTF8Encoding]::new($false)) - Write-Host "Mermaid file saved: $mmdPath" -ForegroundColor Green + if ([string]::IsNullOrWhiteSpace($mermaidDiagram)) { + Write-Warning "Mermaid diagram content is empty; skipping .mmd export." + } else { + $mmdPath = [System.IO.Path]::ChangeExtension($generated, '.mmd') + [System.IO.File]::WriteAllText($mmdPath, $mermaidDiagram, [System.Text.UTF8Encoding]::new($false)) + Write-Host "Mermaid file saved: $mmdPath" -ForegroundColor Green + } } if ($ExportToCsv) { diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index 44be347..a1e0185 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 interactive HTML reports with the Carbon Ember design system.' + 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 - Rebranded all HTML reports with Carbon Ember design (Geist/Geist Mono typography, pill-style tabs, neutral dark theme). Added Get-CustomSecurityAttributesReport with multi-entity support (users, devices, enterprise apps), dynamic attribute set discovery, and coverage metrics. Fixed XSS vulnerabilities in HTML output. Added equal-width tabs, dark mode pagination, and locked DataTable column widths. Added skuFamily for OS edition detection, Windows 11 25H2 support. Added CustomSecAttributeAssignment.Read.All and CustomSecAttributeDefinition.Read.All scopes. 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. '@ diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 5057b84..311a0cb 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -31,14 +31,15 @@ if (Test-Path $privatePath) { 'Invoke-RKSolutionsWithConnection.ps1', 'Invoke-GraphRequestWithPaging.ps1', 'Send-EmailWithAttachment.ps1', - 'ConvertTo-DateString.ps1' + 'ConvertTo-DateString.ps1', + 'Get-RKSolutionsReportTemplate.ps1' ) foreach ($name in $sharedOrder) { $fp = Join-Path $privatePath $name 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 } @@ -58,5 +59,6 @@ Export-ModuleMember -Function @( 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', 'Get-EntraAdminRolesReport', - 'Get-M365LicenseAssignmentReport' + 'Get-M365LicenseAssignmentReport', + 'Get-CustomSecurityAttributesReport' )