# .TITLE Get Devices by Scope Tag Report .SYNOPSIS Generates comprehensive device reports filtered by Scope Tags with CSV and HTML export options .DESCRIPTION This script connects to Microsoft Graph and retrieves all managed devices from Intune, filtering them by specified Scope Tags. It generates detailed reports showing device status, owner information, enrollment profiles, compliance state, and other critical data. The script supports both CSV and HTML output formats, with the HTML report featuring a management-friendly styled interface. Ideal for multi-school environments or organizations using Scope Tags for administrative delegation, this script helps analyze device distribution and status across different organizational units. .TAGS Devices,Compliance .MINROLE Intune Service Administrator .PERMISSIONS DeviceManagementManagedDevices.Read.All,DeviceManagementRBAC.Read.All .AUTHOR Ugur Koc .VERSION 1.5 .CHANGELOG 1.5 - Fixed managed-device URI construction when no platform or compliance filter is supplied 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 - HTML-encode all report values to prevent markup injection and limit the initial device fetch with select 1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing) 1.0 - Initial release .LASTUPDATE 2026-07-30 .EXAMPLE .\get-devices-by-scopetag.ps1 -IncludeScopeTag "School_A" Gets all devices with the "School_A" scope tag and exports CSV and HTML reports to current directory .EXAMPLE .\get-devices-by-scopetag.ps1 -IncludeScopeTag "School_A,School_B" -ExportPath "C:\Reports" Gets devices from School_A and School_B, exports to both CSV and HTML in the specified directory .EXAMPLE .\get-devices-by-scopetag.ps1 -ExcludeScopeTag "Default" -ExportPath "C:\Reports" Gets all devices except those with only the "Default" scope tag .EXAMPLE .\get-devices-by-scopetag.ps1 -IncludeScopeTag "School_A" -Platform "Windows" -ComplianceState "Compliant" Gets only compliant Windows devices from School_A .NOTES - Requires Microsoft.Graph.Authentication module - Uses Connect-MgGraph and Invoke-MgGraphRequest for all Graph operations - The script makes individual API calls for each device to retrieve scope tag information - Large environments may take several minutes to process due to individual device lookups - HTML report includes sorting and filtering capabilities - CSV export includes all device details for further analysis - Scope Tags must match exactly (case-sensitive) - Devices can have multiple scope tags assigned - The beta API endpoint is required to retrieve scope tag information - Disclaimer: This script is provided AS IS without warranty of any kind. Use it at your own risk. - 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 = "Comma-separated list of Scope Tags to include")] [string]$IncludeScopeTag, [Parameter(Mandatory = $false, HelpMessage = "Comma-separated list of Scope Tags to exclude")] [string]$ExcludeScopeTag, [Parameter(Mandatory = $false, HelpMessage = "Directory path for CSV and HTML exports (defaults to current directory)")] [string]$ExportPath = (Get-Location).Path, [Parameter(Mandatory = $false, HelpMessage = "Filter by specific platform (Windows, iOS, Android, macOS)")] [ValidateSet("Windows", "iOS", "Android", "macOS", "All")] [string]$Platform = "All", [Parameter(Mandatory = $false, HelpMessage = "Filter by compliance state")] [ValidateSet("Compliant", "NonCompliant", "Unknown", "All")] [string]$ComplianceState = "All", [Parameter(Mandatory = $false, HelpMessage = "Show progress bar during processing")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ShowProgressBar, [Parameter(Mandatory = $false, HelpMessage = "Include detailed device information")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$IncludeDetails, [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 @('ShowProgressBar', 'IncludeDetails')) { $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 { <# .SYNOPSIS Ensures required modules are available and loaded #> param( [string[]]$ModuleNames, [bool]$IsAutomationEnvironment, [bool]$ForceInstall = $false ) foreach ($ModuleName in $ModuleNames) { Write-Verbose "Checking module: $ModuleName" # 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. To resolve this issue: 1. Go to Azure Portal 2. Navigate to your Automation Account 3. Go to 'Modules' > 'Browse Gallery' 4. Search for '$ModuleName' 5. Click 'Import' and wait for installation to complete Alternative: Use PowerShell to import the module: Import-Module Az.Automation Import-AzAutomationModule -AutomationAccountName "YourAccount" -ResourceGroupName "YourRG" -Name "$ModuleName" "@ throw $errorMessage } else { # Local environment - attempt to install Write-Information "Module '$ModuleName' not found. Attempting to install..." -InformationAction Continue if (-not $ForceInstall) { $response = Read-Host "Install module '$ModuleName'? (Y/N)" if ($response -notmatch '^[Yy]') { throw "Module '$ModuleName' is required but installation was declined." } } try { # Check if running as administrator for AllUsers scope $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" } Write-Information "Installing '$ModuleName' in scope '$scope'..." -InformationAction Continue Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue } catch { throw "Failed to install module '$ModuleName': $($_.Exception.Message)" } } } # Import the module try { Write-Verbose "Importing module: $ModuleName" Import-Module -Name $ModuleName -Force -ErrorAction Stop Write-Verbose "✓ Successfully imported '$ModuleName'" } catch { throw "Failed to import module '$ModuleName': $($_.Exception.Message)" } } } # Detect execution environment if ($PSPrivateMetadata.JobId.Guid) { Write-Output "Running inside Azure Automation Runbook" $IsAzureAutomation = $true } else { Write-Output "Running locally in IDE or terminal" $IsAzureAutomation = $false } # Initialize required modules $RequiredModules = @( "Microsoft.Graph.Authentication" ) # MgGraphCommunity gives WAM-free interactive sign-in for local runs if (-not $IsAzureAutomation) { $RequiredModules += "MgGraphCommunity" } try { Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall Write-Verbose "✓ All required modules are available" } catch { Write-Error "Module initialization failed: $_" exit 1 } # ============================================================================ # AUTHENTICATION # ============================================================================ try { if ($IsAzureAutomation) { # Azure Automation - Use Managed Identity Write-Output "Connecting to Microsoft Graph using Managed Identity..." Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph using Managed Identity" } else { # Local execution - WAM-free interactive sign-in via MgGraphCommunity Write-Output "Connecting to Microsoft Graph with interactive authentication..." $Scopes = @( "DeviceManagementManagedDevices.Read.All", "DeviceManagementRBAC.Read.All" ) Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph" } } 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 { # Add delay to respect rate limits 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 "`nRate 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 fetch all scope tag details once function Get-AllScopeTagDetail { Write-Verbose "Fetching all scope tag details..." $Uri = "https://graph.microsoft.com/beta/deviceManagement/roleScopeTags" $scopeTagsResponse = Invoke-MgGraphRequest -Uri $Uri -Method GET $scopeTagDetails = @{ "0" = @{ DisplayName = "Default" Description = "Default scope tag" } } foreach ($scopeTag in $scopeTagsResponse.value) { $scopeTagDetails[$scopeTag.id] = @{ DisplayName = $scopeTag.displayName Description = $scopeTag.description } } Write-Verbose "Retrieved $($scopeTagDetails.Count) scope tags" return $scopeTagDetails } # Function to get scope tag names from IDs using cached data function Get-ScopeTagName { param( [Parameter(Mandatory = $true)] [string[]]$ScopeTagIds, [Parameter(Mandatory = $true)] [hashtable]$ScopeTagCache ) $ScopeTagNames = @() foreach ($TagId in $ScopeTagIds) { if ($ScopeTagCache.ContainsKey($TagId)) { $ScopeTagNames += $ScopeTagCache[$TagId].DisplayName } else { Write-Verbose "Unknown scope tag ID: $TagId" $ScopeTagNames += "Unknown ($TagId)" } } return $ScopeTagNames -join ", " } # Function to format device information function Format-DeviceInfo { param( [Parameter(Mandatory = $true)] [object]$Device, [Parameter(Mandatory = $true)] [hashtable]$ScopeTagCache, [Parameter(Mandatory = $false)] [switch]$IncludeDetails ) # Get scope tag names $ScopeTagNames = if ($Device.roleScopeTagIds -and $Device.roleScopeTagIds.Count -gt 0) { Get-ScopeTagName -ScopeTagIds $Device.roleScopeTagIds -ScopeTagCache $ScopeTagCache } else { "None" } # Format dates $LastCheckIn = if ($Device.lastSyncDateTime -and $Device.lastSyncDateTime -ne "0001-01-01T00:00:00Z") { ([DateTime]::Parse($Device.lastSyncDateTime)).ToString("yyyy-MM-dd HH:mm:ss") } else { "Never" } $EnrollmentDate = if ($Device.enrolledDateTime -and $Device.enrolledDateTime -ne "0001-01-01T00:00:00Z") { ([DateTime]::Parse($Device.enrolledDateTime)).ToString("yyyy-MM-dd") } else { "Unknown" } # Get user principal name $UserPrincipalName = if ($Device.userPrincipalName) { $Device.userPrincipalName } else { "No User Assigned" } # Get enrollment profile name $EnrollmentProfile = if ($Device.enrollmentProfileName) { $Device.enrollmentProfileName } else { "Direct Enrollment" } # Build device info object $DeviceInfo = [PSCustomObject]@{ ScopeTags = $ScopeTagNames DeviceName = $Device.deviceName Platform = $Device.operatingSystem OSVersion = $Device.osVersion Owner = $UserPrincipalName EnrollmentProfile = $EnrollmentProfile LastCheckIn = $LastCheckIn ComplianceState = $Device.complianceState EnrollmentDate = $EnrollmentDate SerialNumber = $Device.serialNumber Model = $Device.model Manufacturer = $Device.manufacturer ManagementState = $Device.managementState Ownership = $Device.managedDeviceOwnerType DeviceId = $Device.id AzureADDeviceId = $Device.azureADDeviceId EnrollmentType = $Device.deviceEnrollmentType AutoPilotEnrolled = $Device.autopilotEnrolled IsEncrypted = $Device.isEncrypted TotalStorageSpace = if ($Device.totalStorageSpaceInBytes) { [math]::Round($Device.totalStorageSpaceInBytes / 1GB, 2).ToString() + " GB" } else { "Unknown" } FreeStorageSpace = if ($Device.freeStorageSpaceInBytes) { [math]::Round($Device.freeStorageSpaceInBytes / 1GB, 2).ToString() + " GB" } else { "Unknown" } } if (-not $IncludeDetails) { $DeviceInfo = $DeviceInfo | Select-Object ScopeTags, DeviceName, Platform, OSVersion, Owner, EnrollmentProfile, LastCheckIn, ComplianceState, EnrollmentDate } return $DeviceInfo } # Function to test if device matches scope tag criteria function Test-DeviceScopeTag { param( [Parameter(Mandatory = $true)] [object]$Device, [Parameter(Mandatory = $true)] [hashtable]$ScopeTagCache, [string[]]$IncludeTags, [string[]]$ExcludeTags ) # Get device's scope tag names $DeviceScopeTags = if ($Device.roleScopeTagIds -and $Device.roleScopeTagIds.Count -gt 0) { $TagNames = @() foreach ($TagId in $Device.roleScopeTagIds) { if ($ScopeTagCache.ContainsKey($TagId)) { $TagNames += $ScopeTagCache[$TagId].DisplayName } } $TagNames } else { @() } # Check exclude tags first if ($ExcludeTags -and $ExcludeTags.Count -gt 0) { foreach ($ExcludeTag in $ExcludeTags) { if ($DeviceScopeTags -contains $ExcludeTag) { return $false } } } # Check include tags if ($IncludeTags -and $IncludeTags.Count -gt 0) { foreach ($IncludeTag in $IncludeTags) { if ($DeviceScopeTags -contains $IncludeTag) { return $true } } return $false } # If no include tags specified, include all (unless excluded) return $true } # Function to generate HTML report function New-HTMLReport { param( [Parameter(Mandatory = $true)] [array]$Devices, [Parameter(Mandatory = $true)] [string]$ReportPath, [string[]]$IncludeTags, [string[]]$ExcludeTags ) $ReportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $TotalDevices = $Devices.Count # Group devices by various categories $DevicesByPlatform = $Devices | Group-Object Platform $DevicesByCompliance = $Devices | Group-Object ComplianceState $DevicesByScopeTag = $Devices | Group-Object ScopeTags # Build HTML content $HTMLContent = @"
| Scope Tags | Device Name | Platform | OS Version | Owner | Enrollment Profile | Last Check-In | Compliance | Enrolled Date |
|---|---|---|---|---|---|---|---|---|
| $EncodedScopeTags | $EncodedDeviceName | $EncodedPlatform | $EncodedOSVersion | $EncodedOwner | $EncodedEnrollmentProfile | $EncodedLastCheckIn | $EncodedComplianceState | $EncodedEnrollmentDate |