<# .TITLE Multi-Admin Approval Compliance Dashboard Report .SYNOPSIS Generate comprehensive compliance reports on Multi-Admin Approval (MAA) usage and coverage in Intune. .DESCRIPTION This script connects to Microsoft Graph and analyzes Multi-Admin Approval configurations, usage patterns, and compliance metrics across your Intune environment. It generates detailed reports showing MAA coverage gaps, approval statistics, admin permissions, and trends. The script helps organizations ensure proper implementation of MAA controls and identify areas for security improvement. Reports are generated in both HTML and CSV formats for different audiences. .TAGS Compliance,Reporting,Security,MAA,Governance .MINROLE Intune Administrator .PERMISSIONS DeviceManagementConfiguration.Read.All,DeviceManagementApps.Read.All,DeviceManagementManagedDevices.Read.All,DeviceManagementRBAC.Read.All,DeviceManagementScripts.Read.All,Directory.Read.All .AUTHOR Ugur Koc .VERSION 1.5 .CHANGELOG 1.5 - Declare and request DeviceManagementScripts.Read.All for platform-script inventory 1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors 1.3 - Azure Automation now records script progress, outcomes, and summaries in job history 1.2 - Approver status is now resolved from MAA policy approver groups (transitive membership) to populate IsApprover, ApproversCount and AdminsWithoutMAA; resource coverage table computes per-category protected counts from MAA policy types instead of a hardcoded zero; role assignment group members are resolved via transitiveMembers; resource list calls request only the fields used; single-result Graph collections are wrapped in @() so counts are accurate; unused -DetailedAnalysis switch and AuditLog.Read.All permission removed 1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); report auto-open failures no longer abort the script; device management scripts are queried via the beta endpoint and approvers are resolved by enumerating role assignment group members 1.0 - Initial release .LASTUPDATE 2026-07-30 .EXAMPLE .\get-maa-compliance-report.ps1 Generates MAA compliance reports in current directory with default 30-day analysis period .EXAMPLE .\get-maa-compliance-report.ps1 -OutputPath "C:\Reports" -DaysToAnalyze 90 Generates reports with 90-day analysis period and saves to specified directory .EXAMPLE .\get-maa-compliance-report.ps1 -OutputPath "C:\Reports" -IncludeRecommendations "true" Generates detailed reports with security recommendations .NOTES - Requires Microsoft.Graph.Authentication module - Supports both local execution and Azure Automation Runbook environments - Generates both HTML dashboard and CSV data exports - HTML report includes charts and visualizations - CSV exports enable further analysis in Excel or Power BI - Consider running monthly for compliance tracking - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows #> [CmdletBinding()] param( [Parameter(Mandatory = $false, HelpMessage = "Directory path to save reports")] [ValidateNotNullOrEmpty()] [string]$OutputPath = ".", [Parameter(Mandatory = $false, HelpMessage = "Number of days to analyze for historical data")] [ValidateRange(1, 365)] [int]$DaysToAnalyze = 30, [Parameter(Mandatory = $false, HelpMessage = "Include detailed security recommendations")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$IncludeRecommendations, [Parameter(Mandatory = $false, HelpMessage = "Export individual CSV files for each section")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ExportDetailedCSV, [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ForceModuleInstall ) # Normalize the local module-install override for Azure Automation parameter binding. $forceModuleInstallRaw = [string]$ForceModuleInstall Remove-Variable -Name ForceModuleInstall if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) { $ForceModuleInstall = $false } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) { $ForceModuleInstall = $true } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) { $ForceModuleInstall = $false } else { throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false." } # Azure Automation supplies portal parameter values as strings. Normalize the # public boolean parameters once so local and runbook execution use real booleans. foreach ($runbookBooleanParameter in @('IncludeRecommendations', 'ExportDetailedCSV')) { $runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly) Remove-Variable -Name $runbookBooleanParameter if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) { Set-Variable -Name $runbookBooleanParameter -Value $false continue } switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) { { $_ -in @("true", "1", '$true') } { Set-Variable -Name $runbookBooleanParameter -Value $true } { $_ -in @("false", "0", '$false') } { Set-Variable -Name $runbookBooleanParameter -Value $false } default { throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false." } } } # ============================================================================ # ENVIRONMENT DETECTION AND SETUP # ============================================================================ function Initialize-RequiredModule { param( [string[]]$ModuleNames, [bool]$IsAutomationEnvironment, [bool]$ForceInstall = $false ) foreach ($ModuleName in $ModuleNames) { Write-Verbose "Checking module: $ModuleName" # Check if module is already loaded $loadedModule = Get-Module -Name $ModuleName if ($loadedModule) { Write-Verbose "Module '$ModuleName' is already loaded (version $($loadedModule.Version))" continue } # Check if module is available $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1 if (-not $module) { if ($IsAutomationEnvironment) { $errorMessage = @" Module '$ModuleName' is not available in this Azure Automation Account. Please install it from the Browse Gallery in the Azure portal. Required modules: $($ModuleNames -join ', ') "@ Write-Error $errorMessage throw "Required module not found: $ModuleName" } else { Write-Information "Module '$ModuleName' not found. Installing..." -InformationAction Continue if (-not $ForceInstall) { $response = Read-Host "Module '$ModuleName' is required but not installed. Install it now? (Y/N)" if ($response -ne 'Y' -and $response -ne 'y') { Write-Error "Module installation cancelled. Cannot proceed without required module." exit 1 } } try { Write-Information "Installing module: $ModuleName" -InformationAction Continue Install-Module -Name $ModuleName -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop Write-Information "✓ Successfully installed module: $ModuleName" -InformationAction Continue # Refresh available modules $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1 } catch { Write-Error "Failed to install module '$ModuleName': $($_.Exception.Message)" Write-Error "Please install manually: Install-Module -Name $ModuleName -Scope CurrentUser" exit 1 } } } # Import module only if not already loaded if (-not (Get-Module -Name $ModuleName)) { try { Import-Module $ModuleName -ErrorAction Stop Write-Verbose "✓ Successfully imported module: $ModuleName" } catch { # If import fails due to version conflict, try removing and re-importing if ($_.Exception.Message -like "*Assembly with same name is already loaded*") { Write-Warning "Module version conflict detected. Using existing loaded version." } else { Write-Error "Failed to import module '$ModuleName': $($_.Exception.Message)" exit 1 } } } } } # ============================================================================ # MODULES AND AUTHENTICATION # ============================================================================ # Check if running in Azure Automation $RunningInAzureAutomation = $null -ne $env:AUTOMATION_ASSET_ACCOUNTID # Required modules $RequiredModules = @( "Microsoft.Graph.Authentication" ) # MgGraphCommunity gives WAM-free interactive sign-in for local runs if (-not $RunningInAzureAutomation) { $RequiredModules += "MgGraphCommunity" } # Initialize required modules Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $RunningInAzureAutomation -ForceInstall $ForceModuleInstall # Connect to Microsoft Graph try { Write-Output "Connecting to Microsoft Graph..." if ($RunningInAzureAutomation) { # Use Managed Identity in Azure Automation Connect-MgGraph -Identity -NoWelcome Write-Output "✓ Connected to Microsoft Graph using Managed Identity" } else { # Use interactive authentication for local execution $Scopes = @( "DeviceManagementConfiguration.Read.All", "DeviceManagementApps.Read.All", "DeviceManagementManagedDevices.Read.All", "DeviceManagementRBAC.Read.All", "DeviceManagementScripts.Read.All", "Directory.Read.All" ) Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome Write-Output "✓ Connected to Microsoft Graph with interactive authentication" } } catch { Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)" exit 1 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ # Function to get all pages of results from Graph API function Get-MgGraphAllPage { param( [Parameter(Mandatory = $true)] [string]$Uri, [int]$DelayMs = 100 ) $AllResults = @() $NextLink = $Uri $RequestCount = 0 do { try { if ($RequestCount -gt 0) { Start-Sleep -Milliseconds $DelayMs } $Response = Invoke-MgGraphRequest -Uri $NextLink -Method GET $RequestCount++ if ($null -ne $Response.value) { $AllResults += $Response.value } else { $AllResults += $Response } $NextLink = $Response.'@odata.nextLink' } catch { if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") { Write-Information "Rate limit hit, waiting 60 seconds..." -InformationAction Continue Start-Sleep -Seconds 60 continue } throw "Error fetching data from $NextLink : $($_.Exception.Message)" } } while ($NextLink) return $AllResults } # Function to get MAA policies function Get-MAAPolicy { try { Write-Information "Retrieving MAA policies..." -InformationAction Continue $Uri = "https://graph.microsoft.com/beta/deviceManagement/operationApprovalPolicies" $Policies = Get-MgGraphAllPage -Uri $Uri Write-Information "✓ Found $($Policies.Count) MAA policies" -InformationAction Continue return $Policies } catch { Write-Warning "Could not retrieve MAA policies: $($_.Exception.Message)" return @() } } # Function to get all MAA requests function Get-MAARequest { param( [int]$DaysBack ) try { Write-Information "Retrieving MAA requests from last $DaysBack days..." -InformationAction Continue $Uri = "https://graph.microsoft.com/beta/deviceManagement/operationApprovalRequests" $AllRequests = Get-MgGraphAllPage -Uri $Uri # Filter by date range $StartDate = (Get-Date).AddDays(-$DaysBack) $FilteredRequests = $AllRequests | Where-Object { $RequestDate = if ($_.requestDateTime) { [DateTime]$_.requestDateTime } elseif ($_.createdDateTime) { [DateTime]$_.createdDateTime } else { [DateTime]::MinValue } $RequestDate -ge $StartDate } Write-Information "✓ Found $($FilteredRequests.Count) MAA requests in the specified period" -InformationAction Continue return $FilteredRequests } catch { Write-Warning "Could not retrieve MAA requests: $($_.Exception.Message)" return @() } } # Function to get protected resources function Get-ProtectedResource { try { Write-Information "Identifying MAA-protectable resources..." -InformationAction Continue $Resources = @{ Apps = @() Scripts = @() Policies = @() DeviceActions = @() RBAC = @() } # Get Apps try { $AppsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName" $Apps = Get-MgGraphAllPage -Uri $AppsUri $Resources.Apps = @($Apps | Select-Object id, displayName, '@odata.type') Write-Information " Found $($Resources.Apps.Count) applications" -InformationAction Continue } catch { Write-Warning "Could not retrieve apps: $($_.Exception.Message)" } # Get Scripts try { $ScriptsUri = "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?`$select=id,displayName,fileName" $Scripts = Get-MgGraphAllPage -Uri $ScriptsUri $Resources.Scripts = @($Scripts | Select-Object id, displayName, fileName) Write-Information " Found $($Resources.Scripts.Count) scripts" -InformationAction Continue } catch { Write-Warning "Could not retrieve scripts: $($_.Exception.Message)" } # Get Settings Catalog configuration policies. This is the resource the MAA # 'configurationPolicy' approval type actually gates, so the Policies bucket # must inventory configurationPolicies (beta), not the legacy deviceConfigurations # endpoint (which is a different, non-matching resource set). try { $PoliciesUri = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?`$select=id,name" $Policies = Get-MgGraphAllPage -Uri $PoliciesUri $Resources.Policies = @($Policies | Select-Object id, name, '@odata.type') Write-Information " Found $($Resources.Policies.Count) configuration policies" -InformationAction Continue } catch { Write-Warning "Could not retrieve policies: $($_.Exception.Message)" } # Get RBAC roles try { $RBACUri = "https://graph.microsoft.com/beta/deviceManagement/roleDefinitions?`$select=id,displayName,isBuiltIn" $RBAC = Get-MgGraphAllPage -Uri $RBACUri $Resources.RBAC = @($RBAC | Select-Object id, displayName, isBuiltIn) Write-Information " Found $($Resources.RBAC.Count) RBAC roles" -InformationAction Continue } catch { Write-Warning "Could not retrieve RBAC roles: $($_.Exception.Message)" } return $Resources } catch { Write-Error "Failed to retrieve protected resources: $($_.Exception.Message)" return @{} } } # Function to get approvers and admins function Get-ApproverAndAdmin { param( [array]$ApproverGroupIds = @() ) try { Write-Information "Retrieving administrators and approvers..." -InformationAction Continue $Admins = @() # Resolve approver user ids from the MAA policy approver groups (transitive membership) $ApproverUserIds = @{} foreach ($GroupId in ($ApproverGroupIds | Where-Object { $_ } | Select-Object -Unique)) { try { $ApproverMembersUri = "https://graph.microsoft.com/beta/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName" $ApproverMembers = Get-MgGraphAllPage -Uri $ApproverMembersUri foreach ($Member in @($ApproverMembers | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.user' })) { $ApproverUserIds[$Member.id] = $true } } catch { Write-Warning "Could not retrieve approver group members for $GroupId" } } # Get Intune role assignments try { $RoleAssignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/roleAssignments" $RoleAssignments = Get-MgGraphAllPage -Uri $RoleAssignmentsUri foreach ($Assignment in $RoleAssignments) { # Get role definition $RoleUri = "https://graph.microsoft.com/beta/deviceManagement/roleDefinitions/$($Assignment.roleDefinitionId)" $Role = Invoke-MgGraphRequest -Uri $RoleUri -Method GET # Get members (Intune role assignment members are group ids, so enumerate each group) $Members = $Assignment.members foreach ($MemberId in $Members) { try { $GroupMembersUri = "https://graph.microsoft.com/beta/groups/$MemberId/transitiveMembers?`$select=id,displayName,userPrincipalName" $GroupMembers = Get-MgGraphAllPage -Uri $GroupMembersUri foreach ($User in $GroupMembers) { $Admins += [PSCustomObject]@{ UserId = $User.id UserPrincipalName = $User.userPrincipalName DisplayName = $User.displayName Role = $Role.displayName RoleId = $Role.id AssignmentId = $Assignment.id IsApprover = $ApproverUserIds.ContainsKey($User.id) } } } catch { Write-Warning "Could not retrieve group members for $MemberId" } } } } catch { Write-Warning "Could not retrieve role assignments: $($_.Exception.Message)" } Write-Information "✓ Found $($Admins.Count) administrators" -InformationAction Continue return $Admins } catch { Write-Error "Failed to retrieve administrators: $($_.Exception.Message)" return @() } } # Function to analyze MAA compliance function Get-MAAComplianceMetric { param( [array]$Policies, [array]$Requests, [hashtable]$Resources, [array]$Admins ) $Metrics = @{ TotalPolicies = $Policies.Count ActivePolicies = @($Policies | Where-Object { $_.isEnabled -eq $true }).Count TotalRequests = $Requests.Count PendingRequests = @($Requests | Where-Object { $_.status -eq 0 -or $_.status -eq "pending" }).Count ApprovedRequests = @($Requests | Where-Object { $_.status -eq 1 -or $_.status -eq "approved" }).Count RejectedRequests = @($Requests | Where-Object { $_.status -eq 2 -or $_.status -eq "rejected" }).Count CancelledRequests = @($Requests | Where-Object { $_.status -eq 3 -or $_.status -eq "cancelled" }).Count CompletedRequests = @($Requests | Where-Object { $_.status -eq 4 -or $_.status -eq "completed" }).Count # Resource coverage TotalProtectableResources = 0 ProtectedResources = 0 ProtectedByCategory = @{} CoverageGaps = @() # Approval metrics AverageApprovalTime = 0 MedianApprovalTime = 0 FastestApproval = 0 SlowestApproval = 0 # Admin metrics TotalAdmins = $Admins.Count ApproversCount = @($Admins | Where-Object { $_.IsApprover } | Select-Object -ExpandProperty UserId -Unique).Count AdminsWithoutMAA = @($Admins | Where-Object { -not $_.IsApprover } | Select-Object -ExpandProperty UserPrincipalName -Unique) } # Calculate resource coverage foreach ($Category in $Resources.Keys) { $Metrics.TotalProtectableResources += $Resources[$Category].Count $Metrics.ProtectedByCategory[$Category] = 0 } # Analyze policy coverage: an MAA policy protects every resource of its policy type # Only map policy types whose resources are actually inventoried in $Resources. # MAA approval is enforced per operation type (tenant-wide), so an existing # approval policy of a mapped type protects every resource in that category. # compliancePolicy/endpointSecurityPolicy/device* are intentionally omitted: # their resource instances are not inventoried here, so counting them against # the deviceConfigurations-backed 'Policies' bucket would overstate coverage. $PolicyTypeCategoryMap = @{ app = 'Apps' script = 'Scripts' configurationPolicy = 'Policies' role = 'RBAC' } foreach ($Policy in $Policies) { $PolicyType = [string]$Policy.policyType $Category = $PolicyTypeCategoryMap[$PolicyType] if ($Category -and $Metrics.ProtectedByCategory.ContainsKey($Category)) { $Metrics.ProtectedByCategory[$Category] = @($Resources[$Category]).Count } } $Metrics.ProtectedResources = ($Metrics.ProtectedByCategory.Values | Measure-Object -Sum).Sum # Calculate approval times $ApprovalTimes = @() foreach ($Request in @($Requests | Where-Object { $_.status -eq 1 -or $_.status -eq "approved" })) { if ($Request.requestDateTime -and $Request.approvalDateTime) { $TimeDiff = ([DateTime]$Request.approvalDateTime - [DateTime]$Request.requestDateTime).TotalHours $ApprovalTimes += $TimeDiff } } if ($ApprovalTimes.Count -gt 0) { $Metrics.AverageApprovalTime = [Math]::Round(($ApprovalTimes | Measure-Object -Average).Average, 2) $Sorted = $ApprovalTimes | Sort-Object $Metrics.MedianApprovalTime = [Math]::Round($Sorted[$Sorted.Count / 2], 2) $Metrics.FastestApproval = [Math]::Round($Sorted[0], 2) $Metrics.SlowestApproval = [Math]::Round($Sorted[-1], 2) } # Calculate approval rate $TotalProcessed = $Metrics.ApprovedRequests + $Metrics.RejectedRequests if ($TotalProcessed -gt 0) { $Metrics.ApprovalRate = [Math]::Round(($Metrics.ApprovedRequests / $TotalProcessed) * 100, 2) } else { $Metrics.ApprovalRate = 0 } # Identify coverage gaps if ($Metrics.TotalProtectableResources -gt 0) { $Metrics.CoveragePercentage = [Math]::Round(($Metrics.ProtectedResources / $Metrics.TotalProtectableResources) * 100, 2) } else { $Metrics.CoveragePercentage = 0 } return $Metrics } # Function to generate HTML report function New-HTMLReport { param( [hashtable]$Metrics, [array]$Policies, [array]$Requests, [hashtable]$Resources, [array]$Admins, [bool]$IncludeRecommendations ) $ReportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $HTMLReport = @" MAA Compliance Dashboard Report

🛡️ MAA Compliance Dashboard

Multi-Admin Approval Compliance Report - Generated: $ReportDate
Coverage Rate
$($Metrics.CoveragePercentage)%
Resources Protected
Active Policies
$($Metrics.ActivePolicies)
of $($Metrics.TotalPolicies) Total
Approval Rate
$($Metrics.ApprovalRate)%
Requests Approved
Pending Requests
$($Metrics.PendingRequests)
Awaiting Approval
Avg Approval Time
$($Metrics.AverageApprovalTime)h
Hours to Approve
Total Admins
$($Metrics.TotalAdmins)
With Intune Access

📊 MAA Coverage Analysis

$($Metrics.CoveragePercentage)% Protected
"@ # Add resource coverage details foreach ($ResourceType in $Resources.Keys) { $Total = $Resources[$ResourceType].Count $Protected = if ($Metrics.ProtectedByCategory.ContainsKey($ResourceType)) { $Metrics.ProtectedByCategory[$ResourceType] } else { 0 } $Unprotected = $Total - $Protected $Coverage = if ($Total -gt 0) { [Math]::Round(($Protected / $Total) * 100, 2) } else { 0 } $HTMLReport += @" "@ } $HTMLReport += @"
Resource Type Total Resources Protected Unprotected Coverage %
$ResourceType $Total $Protected $Unprotected $Coverage%

📋 MAA Policy Status

"@ # Add policy details foreach ($Policy in $Policies | Sort-Object -Property displayName) { $Status = if ($Policy.isEnabled) { "Active" } else { "Inactive" } $StatusClass = if ($Policy.isEnabled) { "status-active" } else { "status-inactive" } $CreatedDate = if ($Policy.createdDateTime) { ([DateTime]$Policy.createdDateTime).ToString("yyyy-MM-dd") } else { "N/A" } $ApproverCount = if ($Policy.approvers) { $Policy.approvers.Count } else { 0 } $HTMLReport += @" "@ } $HTMLReport += @"
Policy Name Status Resource Type Approvers Created Date
$($Policy.displayName) $Status $($Policy.resourceType) $ApproverCount approver(s) $CreatedDate

📈 Request Analytics ($DaysToAnalyze Day Period)

Total Requests
$($Metrics.TotalRequests)
Approved
$($Metrics.ApprovedRequests)
Rejected
$($Metrics.RejectedRequests)
Pending
$($Metrics.PendingRequests)

Approval Time Distribution

  • Fastest Approval: $($Metrics.FastestApproval) hours
  • Average Approval: $($Metrics.AverageApprovalTime) hours
  • Median Approval: $($Metrics.MedianApprovalTime) hours
  • Slowest Approval: $($Metrics.SlowestApproval) hours
"@ # Add recommendations if requested if ($IncludeRecommendations) { $HTMLReport += @"

🎯 Security Recommendations

"@ # Check for critical findings if ($Metrics.CoveragePercentage -lt 50) { $HTMLReport += @"
⚠️ Critical: MAA coverage is below 50%. Significant security gaps exist.
"@ } if ($Metrics.ActivePolicies -eq 0) { $HTMLReport += @"
⚠️ Critical: No active MAA policies found. Multi-admin approval is not enforced.
"@ } if ($Metrics.AverageApprovalTime -gt 72) { $HTMLReport += @"
⚠️ Warning: Average approval time exceeds 72 hours. Consider process improvements.
"@ } $HTMLReport += @"

Recommended Actions

  • Increase MAA coverage to at least 80% for critical resources
  • Ensure all sensitive operations require multi-admin approval
  • Regularly review and update approver lists
  • Implement automated notifications for pending requests
  • Document approval policies and procedures
  • Conduct quarterly MAA compliance reviews
  • Train administrators on MAA workflows
  • Monitor approval times and optimize processes

Best Practices

  • Require MAA for all production changes
  • Maintain minimum of 2 approvers per policy
  • Separate requesters from approvers (separation of duties)
  • Set maximum approval time SLAs
  • Regular audit of MAA bypasses and exceptions
  • Integrate MAA with change management processes
"@ } $HTMLReport += @"
"@ return $HTMLReport } # Function to export to CSV function Export-MAADataToCSV { param( [string]$OutputPath, [hashtable]$Metrics, [array]$Policies, [array]$Requests, [hashtable]$Resources, [array]$Admins ) $Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" # Export summary metrics $MetricsCSV = @() foreach ($Key in $Metrics.Keys) { $MetricsCSV += [PSCustomObject]@{ Metric = $Key Value = $Metrics[$Key] Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" } } $MetricsCSV | Export-Csv -Path "$OutputPath\MAA_Metrics_$Timestamp.csv" -NoTypeInformation -Encoding utf8 # Export policies if ($Policies.Count -gt 0) { $Policies | Select-Object displayName, isEnabled, resourceType, createdDateTime, lastModifiedDateTime, @{Name = 'ApproverCount'; Expression = { $_.approvers.Count } } | Export-Csv -Path "$OutputPath\MAA_Policies_$Timestamp.csv" -NoTypeInformation -Encoding utf8 } # Export requests if ($Requests.Count -gt 0) { $Requests | Select-Object id, status, requestDateTime, approvalDateTime, requestor, approver, requestJustification | Export-Csv -Path "$OutputPath\MAA_Requests_$Timestamp.csv" -NoTypeInformation -Encoding utf8 } Write-Information "✓ CSV files exported to $OutputPath" -InformationAction Continue } # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting MAA Compliance Dashboard Report generation..." Write-Output "Analysis Period: Last $DaysToAnalyze days" Write-Output "Output Path: $OutputPath" # Create output directory if it doesn't exist if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } # Step 1: Gather MAA data $MAAPolicies = @(Get-MAAPolicy) $MAARequests = @(Get-MAARequest -DaysBack $DaysToAnalyze) $ProtectedResources = Get-ProtectedResource $ApproverGroupIds = @($MAAPolicies | ForEach-Object { $_.approverGroupIds } | Where-Object { $_ } | Select-Object -Unique) $Administrators = Get-ApproverAndAdmin -ApproverGroupIds $ApproverGroupIds # Step 2: Analyze compliance metrics $ComplianceMetrics = Get-MAAComplianceMetric -Policies $MAAPolicies -Requests $MAARequests -Resources $ProtectedResources -Admins $Administrators Write-Output "Analysis complete:" Write-Output " - Policies: $($MAAPolicies.Count)" Write-Output " - Requests: $($MAARequests.Count)" Write-Output " - Coverage: $($ComplianceMetrics.CoveragePercentage)%" Write-Output " - Approval Rate: $($ComplianceMetrics.ApprovalRate)%" # Step 3: Generate HTML report $HTMLReport = New-HTMLReport -Metrics $ComplianceMetrics -Policies $MAAPolicies -Requests $MAARequests -Resources $ProtectedResources -Admins $Administrators -IncludeRecommendations $IncludeRecommendations $HTMLFileName = "MAA_Compliance_Report_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" $HTMLFullPath = Join-Path $OutputPath $HTMLFileName $HTMLReport | Out-File -FilePath $HTMLFullPath -Encoding UTF8 Write-Output "✓ HTML report saved to: $HTMLFullPath" # Step 4: Export CSV data if requested if ($ExportDetailedCSV) { Export-MAADataToCSV -OutputPath $OutputPath -Metrics $ComplianceMetrics -Policies $MAAPolicies -Requests $MAARequests -Resources $ProtectedResources -Admins $Administrators } # Step 5: Generate summary CSV $SummaryData = [PSCustomObject]@{ ReportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" AnalysisPeriodDays = $DaysToAnalyze TotalPolicies = $ComplianceMetrics.TotalPolicies ActivePolicies = $ComplianceMetrics.ActivePolicies CoveragePercentage = $ComplianceMetrics.CoveragePercentage TotalRequests = $ComplianceMetrics.TotalRequests PendingRequests = $ComplianceMetrics.PendingRequests ApprovalRate = $ComplianceMetrics.ApprovalRate AverageApprovalTimeHours = $ComplianceMetrics.AverageApprovalTime TotalAdmins = $ComplianceMetrics.TotalAdmins } $SummaryFileName = "MAA_Summary_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" $SummaryFullPath = Join-Path $OutputPath $SummaryFileName $SummaryData | Export-Csv -Path $SummaryFullPath -NoTypeInformation -Encoding utf8 Write-Output "✓ Summary CSV saved to: $SummaryFullPath" # Display summary Write-Output " ======================================== MAA Compliance Report Summary ======================================== Coverage Rate: $($ComplianceMetrics.CoveragePercentage)% Active Policies: $($ComplianceMetrics.ActivePolicies) of $($ComplianceMetrics.TotalPolicies) Approval Rate: $($ComplianceMetrics.ApprovalRate)% Pending Requests: $($ComplianceMetrics.PendingRequests) Average Approval Time: $($ComplianceMetrics.AverageApprovalTime) hours ======================================== Reports saved to: $OutputPath ======================================== " # Open HTML report if running locally if (-not $RunningInAzureAutomation) { Write-Output "Opening HTML report in default browser..." try { Start-Process $HTMLFullPath } catch { Write-Warning "Could not open the report automatically: $($_.Exception.Message)" } } } catch { Write-Error "Script execution failed: $($_.Exception.Message)" Write-Error "Stack trace: $($_.ScriptStackTrace)" exit 1 } finally { # Cleanup operations try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Output "Disconnected from Microsoft Graph" } catch { # Ignore disconnect errors } } # Copia sin cambios de https://github.com/ugurkocde/IntuneAutomation (commit 797bb2e). # MIT License # # Copyright (c) 2025 Ugur Koc | Microsoft MVP # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE.