Intune-Zuweisungsreport
Intune bietet keine einfache Möglichkeit, alle Gruppenzuweisungen zentral auszulesen. Mit dem im Video gezeigten Skript könnt ihr zwei Berichte erstellen: einen Ressourcen-Report, der alle Intune-Objekte und deren Zuweisungen zeigt, sowie einen Gruppen-Report, der alle Gruppen mit deren zugewiesenen Ressourcen liefert.
Benötigst du Unterstützung?
Melde dich bei uns!
Hier findest du das im Video genutzte Script. Bitte beachte die Nutzungshinweise im Header des Scripts. Es wird ohne Gewähr bereitgestellt und auf eigene Gefahr genutzt.
Get-IntuneAssignmentReport.ps1
#Requires -Modules Microsoft.Graph.Authentication
<#
.SYNOPSIS
Intune Assignment Report - Analyse aller Intune-Ressourcen und deren Gruppenzuweisungen
.DESCRIPTION
Das Intune Assignment Report-Tool analysiert sämtliche Intune-Ressourcen und deren
Gruppenzuweisungen über die Microsoft Graph API. Es erzeugt professionelle HTML-Reports
aus zwei Perspektiven, um einen vollständigen Überblick über die Zuweisungsstruktur
des Tenants zu erhalten.
Funktionen:
[1] Ressourcen-Sicht - Alle Intune-Objekte mit ihren zugewiesenen Gruppen
Listet pro Kategorie sämtliche Ressourcen auf, zeigt Include- und Exclude-Gruppen
sowie spezielle Zuweisungen (Alle Benutzer / Alle Geräte) an.
[2] Gruppen-Sicht - Alle Gruppen mit den ihnen zugewiesenen Intune-Objekten
Invertiertes Mapping: Pro Entra ID-Gruppe werden alle zugewiesenen Intune-Ressourcen
aufgelistet, gruppiert nach Kategorie.
[3] Beide Reports erstellen
Abgedeckte Intune-Ressourcen:
- Configuration Policies (Device Configurations, Settings Catalog, Administrative Templates)
- Apps (Mobile Apps inkl. Win32, MSI, Store, LOB)
- Compliance Policies
- Windows Update Policies (Update Rings, Driver, Feature, Quality, Expedited Updates)
- Enrollment Profiles (Windows Autopilot, iOS, Android)
- Scripts (PowerShell, Remediation / Device Health, macOS Shell)
Das Tool arbeitet mit dem Microsoft Graph PowerShell SDK und nutzt die Microsoft Graph
REST API (überwiegend Beta-Endpunkte, da für vollständige Abdeckung erforderlich).
Sämtliche Endpunkte werden mit automatischem Paging abgefragt und Gruppennamen über
einen vorab geladenen Cache aufgelöst.
.NOTES
Dateiname: Get-IntuneAssignmentReport.ps1
Autor: itelio GmbH
Voraussetzungen:
- PowerShell 5.1 oder höher
- Microsoft.Graph.Authentication Modul (Microsoft Graph PowerShell SDK)
- Microsoft Graph API-Berechtigungen (siehe unten)
WICHTIG - Ausführungsumgebung:
Das Skript MUSS aus einer normalen PowerShell-Konsole ausgeführt werden.
PowerShell ISE wird NICHT unterstützt, da die Authentifizierung mit Web Account
Manager (WAM) in der ISE nicht funktioniert. Verwenden Sie Windows Terminal,
PowerShell 7, oder die klassische PowerShell-Konsole.
Erforderliche Microsoft Graph API-Berechtigungen (Delegated Permissions):
- DeviceManagementManagedDevices.Read.All
Benötigt für: Grundlegende Geräte-Informationen
- DeviceManagementConfiguration.Read.All
Benötigt für: Configuration Policies, Compliance Policies,
Windows Update Policies und deren Assignments
- DeviceManagementApps.Read.All
Benötigt für: Mobile Apps und deren Assignments
- DeviceManagementServiceConfig.Read.All
Benötigt für: Enrollment Profiles und deren Assignments
- DeviceManagementScripts.Read.All
Benötigt für: PowerShell Scripts, Remediation Scripts,
macOS Shell Scripts und deren Assignments
- Group.Read.All
Benötigt für: Auflösung der Gruppen-IDs in Anzeigenamen
Erforderliche Admin-Rolle zum Erteilen der Berechtigungen:
Zum Consent der oben genannten Graph API-Berechtigungen wird eine der
folgenden Entra ID-Rollen benötigt:
- Global Administrator (empfohlen für initiales Setup)
- Intune Administrator (ausreichend für alle Device Management Scopes)
Beim ersten Aufruf fordert das Skript interaktiv die Anmeldung und den Consent
für alle benötigten Berechtigungen an. Nachfolgende Aufrufe in derselben Session
nutzen die bestehende Authentifizierung.
.EXAMPLE
.\Get-IntuneAssignmentReport.ps1
Startet das interaktive Menü. Wählen Sie die gewünschte Report-Perspektive durch
Eingabe der entsprechenden Nummer.
.EXAMPLE
.\Get-IntuneAssignmentReport.ps1 -ReportMode Beide -OutputPath "C:\Reports"
Erstellt beide Reports ohne interaktives Menü und speichert sie im angegebenen Verzeichnis.
.LINK
https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview
https://learn.microsoft.com/en-us/powershell/microsoftgraph/
.DISCLAIMER
HAFTUNGSAUSSCHLUSS:
Dieses Skript wird "wie besehen" ohne jegliche Gewährleistung bereitgestellt. Die Nutzung
erfolgt auf eigene Gefahr. Die itelio GmbH übernimmt keine Haftung für Schäden, Datenverlust
oder andere Probleme, die durch die Verwendung dieses Skripts entstehen können.
Es wird empfohlen, das Skript zunächst in einer Testumgebung auszuführen und die erzeugten
Reports auf Plausibilität zu prüfen.
#>
[CmdletBinding()]
param(
# Ausgabeverzeichnis für die HTML-Reports
[string]$OutputPath,
# Report-Modus: 'Ressourcen', 'Gruppen' oder 'Beide'
[ValidateSet('Ressourcen', 'Gruppen', 'Beide')]
[string]$ReportMode
)
# OutputPath wird nach der Menüauswahl interaktiv oder per Default bestimmt (siehe unten).
# ============================================================================
# Region: Konfiguration und Hilfsfunktionen
# ============================================================================
$ErrorActionPreference = 'Continue'
# Farbschema (blau/weiß)
$script:HtmlColors = @{
Primary = '#0063B1'
PrimaryDark = '#004A85'
PrimaryLight = '#E8F1FA'
Accent = '#00A4EF'
White = '#FFFFFF'
TextDark = '#1A1A1A'
TextMuted = '#6B7280'
Border = '#D1D5DB'
RowAlt = '#F9FAFB'
Success = '#10B981'
Warning = '#F59E0B'
Danger = '#EF4444'
TagInclude = '#DBEAFE'
TagExclude = '#FEE2E2'
TagAll = '#D1FAE5'
}
function Write-Status {
<#
.SYNOPSIS
Gibt eine formatierte Statusmeldung auf der Konsole aus.
#>
param(
[string]$Message,
[ValidateSet('Info', 'Success', 'Warning', 'Error')]
[string]$Type = 'Info'
)
$colors = @{
Info = 'Cyan'
Success = 'Green'
Warning = 'Yellow'
Error = 'Red'
}
$prefixes = @{
Info = '[i]'
Success = '[✓]'
Warning = '[!]'
Error = '[✗]'
}
Write-Host "$($prefixes[$Type]) $Message" -ForegroundColor $colors[$Type]
}
function Invoke-MgGraphRequestPaged {
<#
.SYNOPSIS
Führt einen Graph-API-Request mit automatischem Paging durch.
.DESCRIPTION
Iteriert über alle @odata.nextLink-Seiten und gibt die kombinierten
.value-Einträge zurück.
#>
param(
[Parameter(Mandatory)]
[string]$Uri,
[string]$Description = ''
)
$allItems = [System.Collections.Generic.List[object]]::new()
try {
$response = Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop
if ($response.value) {
$allItems.AddRange([object[]]$response.value)
}
# Paging: Solange @odata.nextLink vorhanden, weitere Seiten abrufen
while ($response.'@odata.nextLink') {
$response = Invoke-MgGraphRequest -Method GET -Uri $response.'@odata.nextLink' -ErrorAction Stop
if ($response.value) {
$allItems.AddRange([object[]]$response.value)
}
}
}
catch {
$descText = ''
if ($Description) { $descText = " ($Description)" }
Write-Status "Fehler beim Abruf: $Uri$descText - $($_.Exception.Message)" -Type Error
}
return $allItems
}
function Get-ResourceAssignments {
<#
.SYNOPSIS
Ruft die Assignments einer einzelnen Intune-Ressource ab.
.DESCRIPTION
Gibt eine Liste von Assignment-Objekten zurück mit:
- GroupId, GroupName (wird später aufgelöst)
- AssignmentType (Include / Exclude / AllUsers / AllDevices)
#>
param(
[Parameter(Mandatory)]
[string]$AssignmentUri
)
$assignments = [System.Collections.Generic.List[PSCustomObject]]::new()
try {
$response = Invoke-MgGraphRequest -Method GET -Uri $AssignmentUri -ErrorAction Stop
foreach ($assignment in $response.value) {
$target = $assignment.target
if (-not $target) { continue }
$type = $target.'@odata.type'
$groupId = $target.groupId
$assignmentType = switch ($type) {
'#microsoft.graph.groupAssignmentTarget' { 'Include' }
'#microsoft.graph.exclusionGroupAssignmentTarget' { 'Exclude' }
'#microsoft.graph.allLicensedUsersAssignmentTarget' { 'AllUsers' }
'#microsoft.graph.allDevicesAssignmentTarget' { 'AllDevices' }
default { 'Unknown' }
}
$assignments.Add([PSCustomObject]@{
GroupId = $groupId
AssignmentType = $assignmentType
})
}
}
catch {
# Manche Ressourcen haben keine Assignments oder der Endpunkt existiert nicht
Write-Verbose "Assignments nicht abrufbar: $AssignmentUri - $($_.Exception.Message)"
}
return $assignments
}
function Get-AllGroupsLookup {
<#
.SYNOPSIS
Lädt alle Entra ID-Gruppen und erstellt ein ID->Name Lookup-Dictionary.
#>
Write-Status "Lade Entra ID-Gruppen für Namensauflösung..."
$groups = Invoke-MgGraphRequestPaged -Uri 'https://graph.microsoft.com/v1.0/groups?$select=id,displayName&$top=999' -Description 'Gruppen'
$lookup = @{}
foreach ($group in $groups) {
$lookup[$group.id] = $group.displayName
}
Write-Status "$($lookup.Count) Gruppen geladen." -Type Success
return $lookup
}
function Resolve-GroupName {
<#
.SYNOPSIS
Löst eine GroupId in den Anzeigenamen auf.
Fällt auf die ID zurück, wenn nicht im Cache.
#>
param(
[string]$GroupId,
[hashtable]$GroupLookup
)
if ([string]::IsNullOrEmpty($GroupId)) { return $null }
if ($GroupLookup.ContainsKey($GroupId)) {
return $GroupLookup[$GroupId]
}
# Einzelabruf als Fallback
try {
$group = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId`?`$select=displayName" -ErrorAction Stop
$name = $group.displayName
$GroupLookup[$GroupId] = $name
return $name
}
catch {
return $GroupId # ID als Fallback
}
}
# ============================================================================
# Region: Datensammlung - Intune-Ressourcen
# ============================================================================
function Get-IntuneResources {
<#
.SYNOPSIS
Sammelt alle Intune-Ressourcen inkl. Assignments aus sämtlichen Kategorien.
.OUTPUTS
Array von PSCustomObjects mit: Category, SubCategory, Name, Type, Id,
CreatedDateTime, LastModifiedDateTime, Assignments
#>
param(
[hashtable]$GroupLookup
)
$allResources = [System.Collections.Generic.List[PSCustomObject]]::new()
# -----------------------------------------------------------------
# Kategorie-Definitionen: Jede Kategorie hat einen oder mehrere Endpunkte
# -----------------------------------------------------------------
$categories = @(
# --- Configuration Policies ---
@{
Category = 'Configuration Policies'
SubCategory = 'Device Configurations'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = '@odata.type'
},
@{
Category = 'Configuration Policies'
SubCategory = 'Settings Catalog / Endpoint Security'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies(''{id}'')/assignments'
NameProp = 'name'
TypeProp = 'platforms'
},
@{
Category = 'Configuration Policies'
SubCategory = 'Administrative Templates'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
# --- Apps ---
@{
Category = 'Apps'
SubCategory = 'Mobile Apps'
ListUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = '@odata.type'
},
# --- Compliance Policies ---
@{
Category = 'Compliance Policies'
SubCategory = 'Device Compliance'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = '@odata.type'
},
# --- Windows Update Policies ---
@{
Category = 'Windows Update Policies'
SubCategory = 'Update Rings'
ListUri = "https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?`$top=100&`$filter=isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = '@odata.type'
},
@{
Category = 'Windows Update Policies'
SubCategory = 'Driver Update Profiles'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsDriverUpdateProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsDriverUpdateProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Windows Update Policies'
SubCategory = 'Feature Update Profiles'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsFeatureUpdateProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsFeatureUpdateProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Windows Update Policies'
SubCategory = 'Quality Update Profiles'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsQualityUpdateProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsQualityUpdateProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Windows Update Policies'
SubCategory = 'Expedited Quality Updates'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsQualityUpdatePolicies?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsQualityUpdatePolicies(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
# --- Enrollment Profiles ---
@{
Category = 'Enrollment Profiles'
SubCategory = 'Windows Autopilot'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Enrollment Profiles'
SubCategory = 'iOS Enrollment'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/appleUserInitiatedEnrollmentProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/appleUserInitiatedEnrollmentProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Enrollment Profiles'
SubCategory = 'Android Enrollment (Device Owner)'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/androidDeviceOwnerEnrollmentProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/androidDeviceOwnerEnrollmentProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
MergeGroup = 'AndroidEnrollment'
},
@{
Category = 'Enrollment Profiles'
SubCategory = 'Android Enrollment (Work Profile)'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/androidForWorkEnrollmentProfiles?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/androidForWorkEnrollmentProfiles(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
MergeGroup = 'AndroidEnrollment'
},
# --- Scripts ---
@{
Category = 'Scripts'
SubCategory = 'PowerShell Scripts'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Scripts'
SubCategory = 'Remediation Scripts'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
},
@{
Category = 'Scripts'
SubCategory = 'macOS Shell Scripts'
ListUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts?$top=100'
AssignUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts(''{id}'')/assignments'
NameProp = 'displayName'
TypeProp = $null
}
)
# Deduplizierungs-Tracker für Android Enrollment
$androidIds = [System.Collections.Generic.HashSet[string]]::new()
$totalCategories = $categories.Count
$currentCategory = 0
foreach ($cat in $categories) {
$currentCategory++
$pctCategory = [math]::Round(($currentCategory / $totalCategories) * 100)
Write-Progress -Activity "Intune-Daten sammeln" -Status "$($cat.SubCategory)" -PercentComplete $pctCategory
Write-Status "Lade $($cat.SubCategory)..."
$items = Invoke-MgGraphRequestPaged -Uri $cat.ListUri -Description $cat.SubCategory
if (-not $items -or $items.Count -eq 0) {
Write-Status " Keine Einträge gefunden für $($cat.SubCategory)." -Type Warning
continue
}
Write-Status " $($items.Count) Einträge gefunden. Lade Assignments..."
$itemCount = 0
foreach ($item in $items) {
$itemCount++
$itemId = $item.id
# Deduplizierung für Android Enrollment
if ($cat.MergeGroup -eq 'AndroidEnrollment') {
if ($androidIds.Contains($itemId)) { continue }
[void]$androidIds.Add($itemId)
}
# Assignments abrufen
$assignUri = $cat.AssignUri -replace '\{id\}', $itemId
$assignments = Get-ResourceAssignments -AssignmentUri $assignUri
# Assignments mit Gruppennamen anreichern
$resolvedAssignments = foreach ($a in $assignments) {
$groupName = switch ($a.AssignmentType) {
'AllUsers' { 'Alle lizenzierten Benutzer' }
'AllDevices' { 'Alle Geräte' }
default { Resolve-GroupName -GroupId $a.GroupId -GroupLookup $GroupLookup }
}
[PSCustomObject]@{
GroupId = $a.GroupId
GroupName = $groupName
AssignmentType = $a.AssignmentType
}
}
# Typ-Information extrahieren
if ($cat.TypeProp -and $item.($cat.TypeProp)) {
$rawType = $item.($cat.TypeProp)
# OData-Typen kürzen: #microsoft.graph.win32LobApp -> win32LobApp
if ($rawType -match '\.(\w+)$') { $typeValue = $Matches[1] } else { $typeValue = $rawType }
} else {
$typeValue = $cat.SubCategory
}
$resourceName = $item.($cat.NameProp)
if ([string]::IsNullOrEmpty($resourceName)) { $resourceName = "(Kein Name - ID: $itemId)" }
$allResources.Add([PSCustomObject]@{
Category = $cat.Category
SubCategory = $cat.SubCategory
Name = $resourceName
Type = $typeValue
Id = $itemId
CreatedDateTime = $item.createdDateTime
LastModifiedDateTime = $item.lastModifiedDateTime
Assignments = @($resolvedAssignments)
})
# Progress innerhalb der Kategorie
if ($itemCount % 10 -eq 0) {
Write-Progress -Activity "Intune-Daten sammeln" -Status "$($cat.SubCategory) - $itemCount/$($items.Count)" -PercentComplete $pctCategory
}
}
Write-Status " $($cat.SubCategory) abgeschlossen: $itemCount Ressourcen verarbeitet." -Type Success
}
Write-Progress -Activity "Intune-Daten sammeln" -Completed
return $allResources
}
# ============================================================================
# Region: HTML-Report-Generierung
# ============================================================================
function Get-HtmlHead {
<#
.SYNOPSIS
Erzeugt den HTML-Header mit CSS.
#>
param([string]$Title)
$c = $script:HtmlColors
return @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$Title</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
background: #F3F4F6;
color: $($c.TextDark);
line-height: 1.5;
}
/* Header */
.header {
background: linear-gradient(135deg, $($c.Primary), $($c.PrimaryDark));
color: $($c.White);
padding: 2rem 2.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.header h1 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.header .subtitle {
font-size: 0.9rem;
opacity: 0.85;
}
.header .meta {
margin-top: 0.75rem;
font-size: 0.8rem;
opacity: 0.7;
}
/* Container */
.container {
max-width: 1400px;
margin: 1.5rem auto;
padding: 0 1.5rem;
}
/* Summary Cards */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: $($c.White);
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-left: 4px solid $($c.Primary);
}
.summary-card .label {
font-size: 0.8rem;
color: $($c.TextMuted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-card .value {
font-size: 1.75rem;
font-weight: 700;
color: $($c.Primary);
margin-top: 0.25rem;
}
/* Sections */
.section {
background: $($c.White);
border-radius: 8px;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
overflow: hidden;
}
.section-header {
background: $($c.PrimaryLight);
padding: 1rem 1.5rem;
border-bottom: 2px solid $($c.Primary);
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header:hover {
background: #D6E8F6;
}
.section-header h2 {
font-size: 1.1rem;
font-weight: 600;
color: $($c.PrimaryDark);
}
.section-header .badge {
background: $($c.Primary);
color: $($c.White);
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.section-content {
padding: 0;
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead th {
background: $($c.PrimaryDark);
color: $($c.White);
padding: 0.65rem 1rem;
text-align: left;
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
}
tbody tr {
border-bottom: 1px solid $($c.Border);
}
tbody tr:nth-child(even) {
background: $($c.RowAlt);
}
tbody tr:hover {
background: $($c.PrimaryLight);
}
tbody td {
padding: 0.55rem 1rem;
vertical-align: top;
}
/* Tags für Gruppen */
.tag {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
margin: 0.1rem 0.15rem;
white-space: nowrap;
}
.tag-include {
background: $($c.TagInclude);
color: #1E40AF;
border: 1px solid #93C5FD;
}
.tag-exclude {
background: $($c.TagExclude);
color: #991B1B;
border: 1px solid #FCA5A5;
}
.tag-all {
background: $($c.TagAll);
color: #065F46;
border: 1px solid #6EE7B7;
}
.tag-none {
color: $($c.TextMuted);
font-style: italic;
}
/* Gruppen-Sicht spezifisch */
.group-section {
margin-bottom: 1rem;
}
.group-section .group-header {
background: $($c.Primary);
color: $($c.White);
padding: 0.75rem 1.25rem;
border-radius: 6px 6px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.group-section .group-header h3 {
font-size: 1rem;
font-weight: 600;
}
.group-resources {
border: 1px solid $($c.Border);
border-top: none;
border-radius: 0 0 6px 6px;
padding: 0.75rem 1rem;
background: $($c.White);
}
.resource-category {
margin-bottom: 0.5rem;
}
.resource-category .cat-label {
font-size: 0.75rem;
font-weight: 600;
color: $($c.TextMuted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.resource-item {
padding: 0.3rem 0;
font-size: 0.85rem;
display: flex;
justify-content: space-between;
border-bottom: 1px solid #F3F4F6;
}
.resource-item:last-child { border-bottom: none; }
.resource-item .res-name { font-weight: 500; }
.resource-item .res-type { color: $($c.TextMuted); font-size: 0.8rem; }
/* Navigation / TOC */
.toc {
background: $($c.White);
border-radius: 8px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.toc h3 {
font-size: 0.9rem;
color: $($c.PrimaryDark);
margin-bottom: 0.75rem;
}
.toc a {
color: $($c.Primary);
text-decoration: none;
font-size: 0.85rem;
display: inline-block;
margin: 0.2rem 0.75rem 0.2rem 0;
}
.toc a:hover { text-decoration: underline; }
/* Footer */
.footer {
text-align: center;
padding: 2rem;
color: $($c.TextMuted);
font-size: 0.75rem;
}
/* Print */
@media print {
body { background: white; }
.header { box-shadow: none; }
.section { break-inside: avoid; }
.section-header { cursor: default; }
}
</style>
</head>
<body>
"@
}
function Get-HtmlFooter {
return @"
<div class="footer">
Erstellt am $(Get-Date -Format 'dd.MM.yyyy HH:mm') Uhr • Intune Assignment Report • itelio
</div>
</body>
</html>
"@
}
function Format-AssignmentTags {
<#
.SYNOPSIS
Erzeugt HTML-Tags für die Assignments einer Ressource.
#>
param(
[array]$Assignments
)
if (-not $Assignments -or $Assignments.Count -eq 0) {
return '<span class="tag-none">Keine Zuweisung</span>'
}
$html = ''
foreach ($a in $Assignments) {
$class = switch ($a.AssignmentType) {
'Include' { 'tag-include' }
'Exclude' { 'tag-exclude' }
'AllUsers' { 'tag-all' }
'AllDevices' { 'tag-all' }
default { 'tag-include' }
}
$label = switch ($a.AssignmentType) {
'Exclude' { "$($a.GroupName) (Exclude)" }
default { $a.GroupName }
}
$html += "<span class=`"tag $class`">$([System.Web.HttpUtility]::HtmlEncode($label))</span>`n"
}
return $html
}
function Format-DateTime {
<#
.SYNOPSIS
Formatiert einen ISO 8601 DateTime-String von Graph API in dd.MM.yyyy HH:mm.
.DESCRIPTION
Graph API liefert Datumsangaben in ISO 8601 UTC, z.B.:
- 2026-01-14T15:22:25Z
- 2026-01-14T15:22:25.1234567Z
- 2026-02-04T09:14:00.0000000+00:00
[datetime]::Parse() ist kultursensitiv und vertauscht je nach Locale
Monat/Tag. Daher wird hier explizit mit InvariantCulture geparst und
anschließend in Lokalzeit konvertiert.
#>
param([string]$DateString)
if ([string]::IsNullOrEmpty($DateString)) { return '–' }
try {
# DateTimeOffset versteht alle ISO 8601-Varianten kulturunabhängig
$dto = [System.DateTimeOffset]::Parse(
$DateString,
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::None
)
# In Lokalzeit des ausführenden Systems konvertieren
return $dto.LocalDateTime.ToString('dd.MM.yyyy HH:mm')
}
catch {
return $DateString
}
}
function New-ResourceReport {
<#
.SYNOPSIS
Erstellt den HTML-Report aus Ressourcen-Perspektive.
#>
param(
[array]$Resources,
[string]$OutputFile
)
Write-Status "Erstelle Ressourcen-Report..."
# Gruppierung nach Kategorie
$grouped = $Resources | Group-Object -Property Category
# Zusammenfassung berechnen
$totalResources = $Resources.Count
$totalAssigned = ($Resources | Where-Object { $_.Assignments.Count -gt 0 }).Count
$totalUnassigned = $totalResources - $totalAssigned
$totalCategories = $grouped.Count
$uniqueGroups = ($Resources.Assignments | Where-Object { $_.GroupId } | Select-Object -ExpandProperty GroupId -Unique).Count
$sb = [System.Text.StringBuilder]::new()
# HTML Head
[void]$sb.Append((Get-HtmlHead -Title "Intune Ressourcen-Report"))
# Header
[void]$sb.Append(@"
<div class="header">
<h1>Intune Assignment Report – Ressourcen-Sicht</h1>
<div class="subtitle">Übersicht aller Intune-Ressourcen und deren Gruppenzuweisungen</div>
<div class="meta">Erstellt am $(Get-Date -Format 'dd.MM.yyyy') um $(Get-Date -Format 'HH:mm') Uhr • Tenant: $((Get-MgContext).TenantId)</div>
</div>
<div class="container">
"@)
# Summary Cards
[void]$sb.Append(@"
<div class="summary-grid">
<div class="summary-card"><div class="label">Ressourcen gesamt</div><div class="value">$totalResources</div></div>
<div class="summary-card"><div class="label">Mit Zuweisung</div><div class="value">$totalAssigned</div></div>
<div class="summary-card"><div class="label">Ohne Zuweisung</div><div class="value">$totalUnassigned</div></div>
<div class="summary-card"><div class="label">Kategorien</div><div class="value">$totalCategories</div></div>
<div class="summary-card"><div class="label">Verwendete Gruppen</div><div class="value">$uniqueGroups</div></div>
</div>
"@)
# TOC
[void]$sb.Append('<div class="toc"><h3>Kategorien</h3>')
foreach ($group in $grouped) {
$anchorId = ($group.Name -replace '\s+', '-').ToLower()
[void]$sb.Append("<a href=`"#$anchorId`">$([System.Web.HttpUtility]::HtmlEncode($group.Name)) ($($group.Count))</a>")
}
[void]$sb.Append('</div>')
# Sektionen pro Kategorie
foreach ($group in $grouped) {
$anchorId = ($group.Name -replace '\s+', '-').ToLower()
[void]$sb.Append(@"
<div class="section" id="$anchorId">
<div class="section-header">
<h2>$([System.Web.HttpUtility]::HtmlEncode($group.Name))</h2>
<span class="badge">$($group.Count) Einträge</span>
</div>
<div class="section-content">
<table>
<thead>
<tr>
<th>Name</th>
<th>Typ / Plattform</th>
<th>Unterkategorie</th>
<th>Zugewiesene Gruppen</th>
<th>Zuletzt geändert</th>
</tr>
</thead>
<tbody>
"@)
# Sortiert nach Name
$sorted = $group.Group | Sort-Object Name
foreach ($res in $sorted) {
$includeTags = Format-AssignmentTags -Assignments ($res.Assignments | Where-Object { $_.AssignmentType -ne 'Exclude' })
$excludeTags = Format-AssignmentTags -Assignments ($res.Assignments | Where-Object { $_.AssignmentType -eq 'Exclude' })
# Kombinierte Tags: Include zuerst, dann Exclude
$allTags = $includeTags
$excludeOnly = $res.Assignments | Where-Object { $_.AssignmentType -eq 'Exclude' }
if ($excludeOnly) {
$allTags += (Format-AssignmentTags -Assignments $excludeOnly)
}
$escapedName = [System.Web.HttpUtility]::HtmlEncode($res.Name)
$escapedType = [System.Web.HttpUtility]::HtmlEncode($res.Type)
$escapedSub = [System.Web.HttpUtility]::HtmlEncode($res.SubCategory)
$modified = Format-DateTime -DateString $res.LastModifiedDateTime
[void]$sb.Append(@"
<tr>
<td><strong>$escapedName</strong></td>
<td>$escapedType</td>
<td>$escapedSub</td>
<td>$allTags</td>
<td>$modified</td>
</tr>
"@)
}
[void]$sb.Append(@"
</tbody>
</table>
</div>
</div>
"@)
}
[void]$sb.Append('</div>')
[void]$sb.Append((Get-HtmlFooter))
# Datei schreiben
$sb.ToString() | Out-File -FilePath $OutputFile -Encoding UTF8 -Force
Write-Status "Ressourcen-Report erstellt: $OutputFile" -Type Success
}
function New-GroupReport {
<#
.SYNOPSIS
Erstellt den HTML-Report aus Gruppen-Perspektive.
.DESCRIPTION
Invertiert das Mapping: Für jede Gruppe werden alle zugewiesenen
Intune-Ressourcen aufgelistet, gruppiert nach Kategorie.
#>
param(
[array]$Resources,
[string]$OutputFile
)
Write-Status "Erstelle Gruppen-Report..."
# Inverses Mapping aufbauen: GroupId/Name -> Liste von Ressourcen
$groupMap = @{} # Key: GroupId oder 'AllUsers'/'AllDevices'
$groupNames = @{} # Key: GroupId -> Name
foreach ($res in $Resources) {
foreach ($a in $res.Assignments) {
$key = switch ($a.AssignmentType) {
'AllUsers' { '__AllUsers__' }
'AllDevices' { '__AllDevices__' }
default { $a.GroupId }
}
if (-not $groupMap.ContainsKey($key)) {
$groupMap[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
}
$groupMap[$key].Add([PSCustomObject]@{
Category = $res.Category
SubCategory = $res.SubCategory
Name = $res.Name
Type = $res.Type
AssignmentType = $a.AssignmentType
})
# Name merken
if (-not $groupNames.ContainsKey($key)) {
$groupNames[$key] = switch ($key) {
'__AllUsers__' { 'Alle lizenzierten Benutzer' }
'__AllDevices__' { 'Alle Geräte' }
default { $a.GroupName }
}
}
}
}
# Sortierung: Spezialgruppen zuerst, dann alphabetisch
$sortedKeys = $groupMap.Keys | Sort-Object {
switch ($_) {
'__AllUsers__' { '0_' }
'__AllDevices__' { '1_' }
default { "2_$($groupNames[$_])" }
}
}
$sb = [System.Text.StringBuilder]::new()
# HTML Head
[void]$sb.Append((Get-HtmlHead -Title "Intune Gruppen-Report"))
# Header
[void]$sb.Append(@"
<div class="header">
<h1>Intune Assignment Report – Gruppen-Sicht</h1>
<div class="subtitle">Übersicht aller Gruppen und deren zugewiesene Intune-Ressourcen</div>
<div class="meta">Erstellt am $(Get-Date -Format 'dd.MM.yyyy') um $(Get-Date -Format 'HH:mm') Uhr • Tenant: $((Get-MgContext).TenantId)</div>
</div>
<div class="container">
"@)
# Summary
$totalGroups = $groupMap.Count
$maxAssignments = ($groupMap.Values | ForEach-Object { $_.Count } | Measure-Object -Maximum).Maximum
$avgAssignments = [math]::Round(($groupMap.Values | ForEach-Object { $_.Count } | Measure-Object -Average).Average, 1)
[void]$sb.Append(@"
<div class="summary-grid">
<div class="summary-card"><div class="label">Gruppen mit Zuweisungen</div><div class="value">$totalGroups</div></div>
<div class="summary-card"><div class="label">Max. Zuweisungen pro Gruppe</div><div class="value">$maxAssignments</div></div>
<div class="summary-card"><div class="label">Ø Zuweisungen pro Gruppe</div><div class="value">$avgAssignments</div></div>
</div>
"@)
# TOC
[void]$sb.Append('<div class="toc"><h3>Gruppen</h3>')
foreach ($key in $sortedKeys) {
$name = $groupNames[$key]
$anchorId = ($key -replace '[^a-zA-Z0-9]', '').ToLower()
$count = $groupMap[$key].Count
[void]$sb.Append("<a href=`"#g-$anchorId`">$([System.Web.HttpUtility]::HtmlEncode($name)) ($count)</a>")
}
[void]$sb.Append('</div>')
# Pro Gruppe eine Sektion
foreach ($key in $sortedKeys) {
$name = $groupNames[$key]
$anchorId = ($key -replace '[^a-zA-Z0-9]', '').ToLower()
$groupResources = $groupMap[$key]
# Nach Kategorie gruppieren
$byCategory = $groupResources | Group-Object -Property Category
[void]$sb.Append(@"
<div class="section group-section" id="g-$anchorId">
<div class="group-header">
<h3>$([System.Web.HttpUtility]::HtmlEncode($name))</h3>
<span class="badge">$($groupResources.Count) Zuweisungen</span>
</div>
<div class="group-resources">
"@)
foreach ($catGroup in ($byCategory | Sort-Object Name)) {
[void]$sb.Append("<div class=`"resource-category`"><div class=`"cat-label`">$([System.Web.HttpUtility]::HtmlEncode($catGroup.Name))</div>")
foreach ($r in ($catGroup.Group | Sort-Object Name)) {
$escapedName = [System.Web.HttpUtility]::HtmlEncode($r.Name)
$escapedType = [System.Web.HttpUtility]::HtmlEncode($r.Type)
$assignLabel = ''
if ($r.AssignmentType -eq 'Exclude') { $assignLabel = '<span class="tag tag-exclude">Exclude</span>' }
[void]$sb.Append(@"
<div class="resource-item">
<span class="res-name">$escapedName $assignLabel</span>
<span class="res-type">$escapedType</span>
</div>
"@)
}
[void]$sb.Append('</div>')
}
[void]$sb.Append('</div></div>')
}
[void]$sb.Append('</div>')
[void]$sb.Append((Get-HtmlFooter))
$sb.ToString() | Out-File -FilePath $OutputFile -Encoding UTF8 -Force
Write-Status "Gruppen-Report erstellt: $OutputFile" -Type Success
}
# ============================================================================
# Region: Interaktives Menü und Hauptlogik
# ============================================================================
function Show-Menu {
<#
.SYNOPSIS
Zeigt ein interaktives Auswahlmenü an.
#>
Write-Host ""
Write-Host "╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ Intune Assignment Report - itelio ║" -ForegroundColor Cyan
Write-Host "╠══════════════════════════════════════════════════╣" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ [1] Ressourcen-Sicht ║" -ForegroundColor Cyan
Write-Host "║ Alle Intune-Objekte mit Zuweisungen ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ [2] Gruppen-Sicht ║" -ForegroundColor Cyan
Write-Host "║ Alle Gruppen mit zugewiesenen Objekten ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ [3] Beide Reports erstellen ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "║ [0] Beenden ║" -ForegroundColor Cyan
Write-Host "║ ║" -ForegroundColor Cyan
Write-Host "╚══════════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
do {
$choice = Read-Host "Auswahl"
} while ($choice -notin @('1', '2', '3', '0'))
$result = switch ($choice) {
'1' { 'Ressourcen' }
'2' { 'Gruppen' }
'3' { 'Beide' }
'0' { 'Exit' }
}
return $result
}
# ============================================================================
# Region: Hauptprogramm
# ============================================================================
# System.Web laden für HTML-Encoding
Add-Type -AssemblyName System.Web
# Report-Modus bestimmen (Parameter oder interaktiv)
if (-not $ReportMode) {
$menuChoice = Show-Menu
if ($menuChoice -eq 'Exit') {
# Graph-Verbindung trennen, falls eine besteht
if (Get-MgContext -ErrorAction SilentlyContinue) {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Write-Status "Graph-Verbindung getrennt."
}
Write-Status "Beendet."
return
}
$ReportMode = $menuChoice
}
# Ausgabeverzeichnis bestimmen (nur interaktiv, wenn nicht per Parameter übergeben)
if (-not $OutputPath) {
$defaultPath = Join-Path $env:USERPROFILE "Intune-Reports"
Write-Host ""
Write-Host "Ausgabeverzeichnis fuer die HTML-Reports" -ForegroundColor Cyan
Write-Host "Standard: $defaultPath" -ForegroundColor Gray
$userInput = Read-Host "Verzeichnis eingeben (Enter fuer Standard)"
if ([string]::IsNullOrWhiteSpace($userInput)) {
$OutputPath = $defaultPath
} else {
$OutputPath = $userInput.Trim()
}
}
# Verzeichnis prüfen und ggf. erstellen
if (-not (Test-Path $OutputPath)) {
Write-Host ""
$confirm = Read-Host "Verzeichnis '$OutputPath' existiert nicht. Erstellen? (J/N)"
if ($confirm -in @('J', 'j', 'Y', 'y')) {
try {
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
Write-Status "Verzeichnis erstellt: $OutputPath" -Type Success
}
catch {
Write-Status "Verzeichnis konnte nicht erstellt werden: $($_.Exception.Message)" -Type Error
return
}
} else {
Write-Status "Abgebrochen. Kein Ausgabeverzeichnis verfuegbar." -Type Error
return
}
}
# Graph-Verbindung prüfen/herstellen
$context = Get-MgContext
if (-not $context) {
Write-Status "Keine aktive Graph-Verbindung. Starte Authentifizierung..."
try {
Connect-MgGraph -Scopes @(
"DeviceManagementManagedDevices.Read.All",
"DeviceManagementConfiguration.Read.All",
"DeviceManagementApps.Read.All",
"DeviceManagementServiceConfig.Read.All",
"DeviceManagementScripts.Read.All",
"Group.Read.All"
) -ErrorAction Stop
Write-Status "Graph-Verbindung hergestellt." -Type Success
}
catch {
Write-Status "Authentifizierung fehlgeschlagen: $($_.Exception.Message)" -Type Error
Write-Status "Bitte sicherstellen, dass das Modul 'Microsoft.Graph.Authentication' installiert ist." -Type Error
return
}
}
else {
Write-Status "Graph-Verbindung aktiv - Tenant: $($context.TenantId)"
}
# Zeitstempel für Dateinamen
$timestamp = Get-Date -Format 'yyyyMMdd-HHmm'
# Gruppen-Lookup laden
$groupLookup = Get-AllGroupsLookup
# Intune-Ressourcen sammeln
Write-Status "Starte Datensammlung aller Intune-Ressourcen..."
$startTime = Get-Date
$resources = Get-IntuneResources -GroupLookup $groupLookup
$duration = (Get-Date) - $startTime
Write-Status "Datensammlung abgeschlossen: $($resources.Count) Ressourcen in $($duration.ToString('mm\:ss')) min." -Type Success
# Reports erstellen
if ($ReportMode -in @('Ressourcen', 'Beide')) {
$resourceFile = Join-Path $OutputPath "IntuneReport_Ressourcen_$timestamp.html"
New-ResourceReport -Resources $resources -OutputFile $resourceFile
}
if ($ReportMode -in @('Gruppen', 'Beide')) {
$groupFile = Join-Path $OutputPath "IntuneReport_Gruppen_$timestamp.html"
New-GroupReport -Resources $resources -OutputFile $groupFile
}
# Abschluss
Write-Host ""
Write-Status "═══════════════════════════════════════════════" -Type Success
Write-Status "Reports gespeichert in: $OutputPath" -Type Success
Write-Status "═══════════════════════════════════════════════" -Type Success
# Reports im Browser öffnen
if ($ReportMode -in @('Ressourcen', 'Beide')) {
Write-Status "Öffne Ressourcen-Report..."
Start-Process $resourceFile
}
if ($ReportMode -in @('Gruppen', 'Beide')) {
Write-Status "Öffne Gruppen-Report..."
Start-Process $groupFile
}
# Graph-Verbindung trennen
try {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Write-Status "Graph-Verbindung getrennt."
}
catch {
# Kein Fehler wenn keine Verbindung bestand
}