Multi-Admin Approval Compliance Dashboard Report
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.
New to runbook deployment? Follow the step-by-step guide from the Deploy to Azure button to the first scheduled run, including granting Graph permissions to the managed identity.
// QUALITY CHECKS
Validation status
Quality checks
All checks pass- ParsePass
- LintPass
- MetadataPass
- Runbook-readyPass
- Module depsPass
Tests run automatically on every change. What does each check mean?
// REQUIRED PERMISSIONS
Microsoft Graph scopes
DeviceManagementConfiguration.Read.AllAllows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.
DeviceManagementApps.Read.AllAllows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.
DeviceManagementManagedDevices.Read.AllAllows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.
DeviceManagementRBAC.Read.AllDeviceManagementScripts.Read.AllDirectory.Read.AllRunning this as an Azure Automation runbook? These scopes must be granted to the account's managed identity, which has no portal UI. The deployment walkthrough shows the exact Cloud Shell commands.
// CHANGELOG
Version history
Entry · 01
1.5 - Declare and request DeviceManagementScripts.Read.All for platform-script inventory
Entry · 02
1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
Entry · 03
1.3 - Azure Automation now records script progress, outcomes, and summaries in job history
Entry · 04
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
Entry · 05
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
Entry · 06
1.0 - Initial release
// CODE
Source
<#
.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 = @"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MAA Compliance Dashboard Report</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.header {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.header h1 {
color: #2d3748;
font-size: 32px;
margin-bottom: 10px;
}
.header .subtitle {
color: #718096;
font-size: 16px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
transition: transform 0.3s ease;
}
.metric-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.metric-label {
color: #718096;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.metric-value {
font-size: 36px;
font-weight: bold;
color: #2d3748;
margin-bottom: 5px;
}
.metric-sublabel {
color: #a0aec0;
font-size: 12px;
}
.section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
}
.section h2 {
color: #2d3748;
font-size: 24px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e2e8f0;
}
.table-responsive {
overflow-x: auto;
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #f7fafc;
color: #2d3748;
text-align: left;
padding: 12px;
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e2e8f0;
}
td {
padding: 12px;
color: #4a5568;
border-bottom: 1px solid #e2e8f0;
}
tr:hover {
background: #f7fafc;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-active { background: #c6f6d5; color: #22543d; }
.status-inactive { background: #fed7d7; color: #742a2a; }
.status-pending { background: #feebc8; color: #744210; }
.status-approved { background: #c6f6d5; color: #22543d; }
.status-rejected { background: #fed7d7; color: #742a2a; }
.progress-bar {
width: 100%;
height: 30px;
background: #e2e8f0;
border-radius: 15px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.chart-container {
margin: 20px 0;
padding: 20px;
background: #f7fafc;
border-radius: 8px;
}
.recommendation-box {
background: #edf2f7;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
}
.recommendation-box h3 {
color: #2d3748;
margin-bottom: 10px;
}
.recommendation-box ul {
margin-left: 20px;
color: #4a5568;
line-height: 1.8;
}
.alert {
padding: 15px;
margin: 20px 0;
border-radius: 8px;
display: flex;
align-items: center;
}
.alert-warning {
background: #fef5e7;
border: 1px solid #f39c12;
color: #8b6914;
}
.alert-success {
background: #e8f8f5;
border: 1px solid #27ae60;
color: #186a3b;
}
.alert-danger {
background: #fadbd8;
border: 1px solid #e74c3c;
color: #922b21;
}
.footer {
text-align: center;
color: white;
margin-top: 40px;
padding: 20px;
}
@media print {
body { background: white; }
.metric-card { break-inside: avoid; }
.section { break-inside: avoid; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🛡️ MAA Compliance Dashboard</h1>
<div class="subtitle">Multi-Admin Approval Compliance Report - Generated: $ReportDate</div>
</div>
<!-- Key Metrics -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Coverage Rate</div>
<div class="metric-value">$($Metrics.CoveragePercentage)%</div>
<div class="metric-sublabel">Resources Protected</div>
</div>
<div class="metric-card">
<div class="metric-label">Active Policies</div>
<div class="metric-value">$($Metrics.ActivePolicies)</div>
<div class="metric-sublabel">of $($Metrics.TotalPolicies) Total</div>
</div>
<div class="metric-card">
<div class="metric-label">Approval Rate</div>
<div class="metric-value">$($Metrics.ApprovalRate)%</div>
<div class="metric-sublabel">Requests Approved</div>
</div>
<div class="metric-card">
<div class="metric-label">Pending Requests</div>
<div class="metric-value">$($Metrics.PendingRequests)</div>
<div class="metric-sublabel">Awaiting Approval</div>
</div>
<div class="metric-card">
<div class="metric-label">Avg Approval Time</div>
<div class="metric-value">$($Metrics.AverageApprovalTime)h</div>
<div class="metric-sublabel">Hours to Approve</div>
</div>
<div class="metric-card">
<div class="metric-label">Total Admins</div>
<div class="metric-value">$($Metrics.TotalAdmins)</div>
<div class="metric-sublabel">With Intune Access</div>
</div>
</div>
<!-- Coverage Analysis -->
<div class="section">
<h2>📊 MAA Coverage Analysis</h2>
<div class="progress-bar">
<div class="progress-fill" style="width: $($Metrics.CoveragePercentage)%">
$($Metrics.CoveragePercentage)% Protected
</div>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Resource Type</th>
<th>Total Resources</th>
<th>Protected</th>
<th>Unprotected</th>
<th>Coverage %</th>
</tr>
</thead>
<tbody>
"@
# 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 += @"
<tr>
<td><strong>$ResourceType</strong></td>
<td>$Total</td>
<td>$Protected</td>
<td>$Unprotected</td>
<td>$Coverage%</td>
</tr>
"@
}
$HTMLReport += @"
</tbody>
</table>
</div>
</div>
<!-- Policy Status -->
<div class="section">
<h2>📋 MAA Policy Status</h2>
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Policy Name</th>
<th>Status</th>
<th>Resource Type</th>
<th>Approvers</th>
<th>Created Date</th>
</tr>
</thead>
<tbody>
"@
# 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 += @"
<tr>
<td><strong>$($Policy.displayName)</strong></td>
<td><span class="status-badge $StatusClass">$Status</span></td>
<td>$($Policy.resourceType)</td>
<td>$ApproverCount approver(s)</td>
<td>$CreatedDate</td>
</tr>
"@
}
$HTMLReport += @"
</tbody>
</table>
</div>
</div>
<!-- Request Analytics -->
<div class="section">
<h2>📈 Request Analytics ($DaysToAnalyze Day Period)</h2>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Requests</div>
<div class="metric-value">$($Metrics.TotalRequests)</div>
</div>
<div class="metric-card">
<div class="metric-label">Approved</div>
<div class="metric-value" style="color: #27ae60;">$($Metrics.ApprovedRequests)</div>
</div>
<div class="metric-card">
<div class="metric-label">Rejected</div>
<div class="metric-value" style="color: #e74c3c;">$($Metrics.RejectedRequests)</div>
</div>
<div class="metric-card">
<div class="metric-label">Pending</div>
<div class="metric-value" style="color: #f39c12;">$($Metrics.PendingRequests)</div>
</div>
</div>
<div class="chart-container">
<h3>Approval Time Distribution</h3>
<ul>
<li>Fastest Approval: $($Metrics.FastestApproval) hours</li>
<li>Average Approval: $($Metrics.AverageApprovalTime) hours</li>
<li>Median Approval: $($Metrics.MedianApprovalTime) hours</li>
<li>Slowest Approval: $($Metrics.SlowestApproval) hours</li>
</ul>
</div>
</div>
"@
# Add recommendations if requested
if ($IncludeRecommendations) {
$HTMLReport += @"
<!-- Recommendations -->
<div class="section">
<h2>🎯 Security Recommendations</h2>
"@
# Check for critical findings
if ($Metrics.CoveragePercentage -lt 50) {
$HTMLReport += @"
<div class="alert alert-danger">
<strong>⚠️ Critical:</strong> MAA coverage is below 50%. Significant security gaps exist.
</div>
"@
}
if ($Metrics.ActivePolicies -eq 0) {
$HTMLReport += @"
<div class="alert alert-danger">
<strong>⚠️ Critical:</strong> No active MAA policies found. Multi-admin approval is not enforced.
</div>
"@
}
if ($Metrics.AverageApprovalTime -gt 72) {
$HTMLReport += @"
<div class="alert alert-warning">
<strong>⚠️ Warning:</strong> Average approval time exceeds 72 hours. Consider process improvements.
</div>
"@
}
$HTMLReport += @"
<div class="recommendation-box">
<h3>Recommended Actions</h3>
<ul>
<li>Increase MAA coverage to at least 80% for critical resources</li>
<li>Ensure all sensitive operations require multi-admin approval</li>
<li>Regularly review and update approver lists</li>
<li>Implement automated notifications for pending requests</li>
<li>Document approval policies and procedures</li>
<li>Conduct quarterly MAA compliance reviews</li>
<li>Train administrators on MAA workflows</li>
<li>Monitor approval times and optimize processes</li>
</ul>
</div>
<div class="recommendation-box">
<h3>Best Practices</h3>
<ul>
<li>Require MAA for all production changes</li>
<li>Maintain minimum of 2 approvers per policy</li>
<li>Separate requesters from approvers (separation of duties)</li>
<li>Set maximum approval time SLAs</li>
<li>Regular audit of MAA bypasses and exceptions</li>
<li>Integrate MAA with change management processes</li>
</ul>
</div>
</div>
"@
}
$HTMLReport += @"
<div class="footer">
<p>© 2025 MAA Compliance Report | Generated by Intune Automation Suite</p>
<p>Report Period: Last $DaysToAnalyze days | Next Review: $(([DateTime]::Now.AddDays(30)).ToString("yyyy-MM-dd"))</p>
</div>
</div>
<script>
// Add any interactive features here
document.addEventListener('DOMContentLoaded', function() {
console.log('MAA Compliance Report Loaded');
});
</script>
</body>
</html>
"@
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
}
}
// NOTES
Author 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
// RELATED
Scripts that travel together.
Picked by shared tags, category, and script type — nothing magic, just metadata overlap.
Device Compliance Report
This script connects to Microsoft Graph, retrieves managed devices and their compliance status, and generates a detailed report in both CSV and HTML formats. The report includes device details, compliance status, and summary statistics.
ComplianceReportingNon-Compliant Devices with Reasons Report
This script connects to Microsoft Graph, retrieves every managed device whose compliance state is non-compliant (and optionally error / in grace period), then drills into each device's compliance policy states and the underlying setting states to surface the exact reason a device is non-compliant. For every failing setting it reports the owning policy, the setting name, the reported state, the current value, and any error code/description. Results are exported to both CSV (one row per failing setting) and a summary HTML report.
ComplianceReportingMulti-Admin Approval Pending Requests Monitor
This script is designed to run as a scheduled Azure Automation runbook that monitors Multi-Admin Approval requests in Microsoft Intune and identifies pending approval requests. It tracks new requests, monitors request age, identifies approvers, and sends email notifications to administrators with detailed request information and direct links to the Intune portal. The script helps maintain security compliance by ensuring timely review of administrative changes and provides visibility into the MAA approval workflow. Key Features: - Monitors all MAA pending requests across protected resources - Tracks request age and highlights urgent requests - Identifies and notifies appropriate approvers - Provides direct links to Intune portal for quick action - Tracks previously notified requests to avoid spam - Sends escalation alerts for aging requests - Supports both Azure Automation runbook and local execution - HTML formatted email reports with actionable insights - Uses Microsoft Graph Mail API exclusively
SecurityCompliance