IntuneOperator.psm1
|
[CmdletBinding()] param() $baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) $script:PSModuleInfo = Import-PowerShellDataFile -Path "$PSScriptRoot\$baseName.psd1" $script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ } $scriptName = $script:PSModuleInfo.Name Write-Debug "[$scriptName] - Importing module" #region [functions] - [private] Write-Debug "[$scriptName] - [functions] - [private] - Processing folder" #region [functions] - [private] - [ConvertTo-IntuneDeviceSummary] Write-Debug "[$scriptName] - [functions] - [private] - [ConvertTo-IntuneDeviceSummary] - Importing" function ConvertTo-IntuneDeviceSummary { <# .SYNOPSIS Converts a managed device Graph object to the module's Intune device summary shape. .DESCRIPTION Maps selected managed device properties to a stable output object used by Get-IntuneDevice. Expects any enrichment (for example EnrolledBy resolution) to be done before conversion. .PARAMETER Device A Microsoft Graph managed device object. .EXAMPLE $device | ConvertTo-IntuneDeviceSummary Converts each input device object to the standardized output object. .INPUTS System.Object .OUTPUTS PSCustomObject #> [OutputType([PSCustomObject])] [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNull()] [object]$Device ) process { $lastSyncDateTime = $null # Keep output type stable even if Graph returns an unexpected timestamp format. if ($null -ne $Device.lastSyncDateTime -and -not [string]::IsNullOrWhiteSpace([string]$Device.lastSyncDateTime)) { try { $lastSyncDateTime = [datetime]$Device.lastSyncDateTime } catch { $lastSyncDateTime = $null } } # Stable output contract consumed by Get-IntuneDevice callers. [PSCustomObject]@{ DeviceName = [string]$Device.deviceName PrimaryUser = [string]$Device.userPrincipalName DeviceManufacturer = [string]$Device.manufacturer DeviceModel = [string]$Device.model OperatingSystem = [string]$Device.operatingSystem SerialNumber = [string]$Device.serialNumber Compliance = [string]$Device.complianceState LastSyncDateTime = $lastSyncDateTime } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [private] - [ConvertTo-IntuneDeviceSummary] - Done" #endregion [functions] - [private] - [ConvertTo-IntuneDeviceSummary] #region [functions] - [private] - [Get-FirstPropertyValue] Write-Debug "[$scriptName] - [functions] - [private] - [Get-FirstPropertyValue] - Importing" function Get-FirstPropertyValue { <# .SYNOPSIS Returns the first non-empty property value from an object or dictionary. .DESCRIPTION Checks each property name in order against the input object, dictionary keys, and an AdditionalProperties dictionary when present. Returns the first value that is not null and not empty/whitespace when converted to string. .PARAMETER InputObject The object or dictionary to inspect. .PARAMETER PropertyNames The ordered list of property names to evaluate. .PARAMETER DefaultValue The value to return when none of the requested properties contain a value. .EXAMPLE Get-FirstPropertyValue -InputObject $Object -PropertyNames @('displayName', 'name') Returns the first populated value found in `displayName` or `name`. .INPUTS System.Object .OUTPUTS System.Object #> [OutputType([object])] [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowNull()] [object]$InputObject, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string[]]$PropertyNames, [Parameter(Mandatory = $false)] [AllowNull()] [object]$DefaultValue = $null ) process { if ($null -eq $InputObject) { return $DefaultValue } $additionalProperties = $InputObject.PSObject.Properties['AdditionalProperties'] $additionalPropertiesValue = $null if ($null -ne $additionalProperties -and $additionalProperties.Value -is [System.Collections.IDictionary]) { $additionalPropertiesValue = $additionalProperties.Value } foreach ($propertyName in $PropertyNames) { if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($propertyName)) { $dictionaryValue = $InputObject[$propertyName] if ($null -ne $dictionaryValue -and -not [string]::IsNullOrWhiteSpace([string]$dictionaryValue)) { return $dictionaryValue } } $property = $InputObject.PSObject.Properties[$propertyName] if ($null -ne $property) { $value = $property.Value if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { return $value } } if ($null -ne $additionalPropertiesValue -and $additionalPropertiesValue.Contains($propertyName)) { $apValue = $additionalPropertiesValue[$propertyName] if ($null -ne $apValue -and -not [string]::IsNullOrWhiteSpace([string]$apValue)) { return $apValue } } } return $DefaultValue } } Write-Debug "[$scriptName] - [functions] - [private] - [Get-FirstPropertyValue] - Done" #endregion [functions] - [private] - [Get-FirstPropertyValue] #region [functions] - [private] - [Invoke-GraphGet] Write-Debug "[$scriptName] - [functions] - [private] - [Invoke-GraphGet] - Importing" function Invoke-GraphGet { <# .SYNOPSIS Invokes a GET request against the Microsoft Graph API with error handling and automatic pagination. .DESCRIPTION Wrapper function for making authenticated GET requests to the Microsoft Graph API. Provides consistent error handling, verbose output, and automatic pagination handling for all Graph API calls. When the response contains a 'value' collection and '@odata.nextLink', automatically follows pagination to retrieve all results. Requires an established Microsoft Graph connection via Connect-MgGraph. .PARAMETER Uri The full URI of the Graph API endpoint to query. .EXAMPLE Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices" Retrieves all managed devices from the Graph API, automatically following pagination links. .EXAMPLE Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/12345" Retrieves a single managed device by ID (no pagination applies). .INPUTS System.String .OUTPUTS PSObject .NOTES Part of the Intune Device Login helper functions. Requires Microsoft.Graph PowerShell module with active connection. Automatically handles pagination for collection responses. #> [OutputType([PSObject])] [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = "The full URI of the Graph API endpoint" )] [ValidateNotNullOrEmpty()] [string]$Uri ) process { Write-Verbose -Message "GET $Uri" try { $splat = @{ Method = 'GET' Uri = $Uri ErrorAction = 'Stop' } $response = Invoke-MgGraphRequest @splat # Check if response has pagination (value collection with nextLink) if ($null -ne $response.value -and $null -ne $response.'@odata.nextLink') { Write-Verbose -Message "Response contains pagination, retrieving all pages" $allValues = [System.Collections.Generic.List[object]]::new() $allValues.AddRange($response.value) $nextLink = $response.'@odata.nextLink' $pageCount = 1 while ($null -ne $nextLink) { $pageCount++ Write-Verbose -Message "Following pagination link (page $pageCount, current items: $($allValues.Count))" $splat.Uri = $nextLink $nextResponse = Invoke-MgGraphRequest @splat if ($null -ne $nextResponse.value) { $allValues.AddRange($nextResponse.value) } $nextLink = $nextResponse.'@odata.nextLink' } Write-Verbose -Message "Pagination complete: retrieved $($allValues.Count) total items across $pageCount pages" # Return modified response with all values $response.value = $allValues.ToArray() $response.PSObject.Properties.Remove('@odata.nextLink') } return $response } catch { $Exception = [Exception]::new("Graph request failed for '$Uri': $($_.Exception.Message)", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'GraphRequestFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $Uri ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [private] - [Invoke-GraphGet] - Done" #endregion [functions] - [private] - [Invoke-GraphGet] #region [functions] - [private] - [Resolve-EntraUserById] Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-EntraUserById] - Importing" function Resolve-EntraUserById { <# .SYNOPSIS Resolves an Entra ID user by user ID to retrieve user principal name and other details. .DESCRIPTION Queries Microsoft Graph for a specific user by their user ID (object ID). Returns user object including userPrincipalName for reporting and audit purposes. Handles cases where user may no longer exist in Entra ID. .PARAMETER UserId The Entra ID user object identifier (GUID). .EXAMPLE Resolve-EntraUserById -UserId "d1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" Returns the user object with UPN for the specified user ID. .INPUTS System.String .OUTPUTS PSObject .NOTES Part of the Intune Device Login helper functions. Uses Microsoft Graph /v1.0 endpoint. Requires User.Read.All scope. Returns a minimal user object if user cannot be found. #> [OutputType([PSObject])] [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = "The Entra ID user object ID" )] [ValidateNotNullOrEmpty()] [string]$UserId ) begin { $baseUri = 'https://graph.microsoft.com/v1.0/users' } process { $uri = "$baseUri/$UserId" try { Invoke-GraphGet -Uri $uri } catch { Write-Verbose -Message "Could not resolve user ID '$UserId': $($_.Exception.Message)" # Return a minimal object with the ID and a placeholder UPN [PSCustomObject]@{ id = $UserId userPrincipalName = "Unknown (ID: $UserId)" } } } } Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-EntraUserById] - Done" #endregion [functions] - [private] - [Resolve-EntraUserById] #region [functions] - [private] - [Resolve-IntuneDeviceByName] Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByName] - Importing" function Resolve-IntuneDeviceByName { <# .SYNOPSIS Resolves one or more Intune managed devices by device name. .DESCRIPTION Queries Intune managed devices using the device name filter. Performs case-insensitive exact match searching via OData filter. Returns all devices matching the specified name. .PARAMETER Name The device name to search for in Intune managed devices. .EXAMPLE Resolve-IntuneDeviceByName -Name "PC-001" Returns the managed device object matching the name "PC-001", if found. .INPUTS System.String .OUTPUTS PSObject[] .NOTES Part of the Intune Device Login helper functions. Uses Microsoft Graph /beta endpoint. Requires DeviceManagementManagedDevices.Read.All scope. #> [OutputType([PSCustomObject[]])] [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = "The device name to resolve" )] [ValidateNotNullOrEmpty()] [string]$Name ) begin { $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' } process { # Escape only the string literal content; keep OData filter syntax intact. $escapedName = $Name.Replace("'", "''") $filter = "deviceName eq '$escapedName'" $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,enrolledByUserId,complianceState,lastSyncDateTime' $candidateUris = @( "$baseUri`?`$filter=$filter&`$select=$select", "$baseUri`?`$filter=$filter", "$baseUri`?`$select=$select", $baseUri ) $resp = $null $lastBadRequestError = $null foreach ($candidateUri in $candidateUris) { try { $resp = Invoke-GraphGet -Uri $candidateUri break } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'BadRequest|400') { $lastBadRequestError = $_ Write-Verbose -Message "Managed device query returned BadRequest for URI '$candidateUri'. Trying next fallback." continue } throw } } if ($null -eq $resp -and $null -ne $lastBadRequestError) { throw $lastBadRequestError } $devices = @() if ($null -ne $resp) { if ($null -ne $resp.value) { $devices = @($resp.value) } else { $devices = @($resp) } } # Keep exact name semantics even after fallback to unfiltered Graph query. $matchedDevices = @($devices | Where-Object -FilterScript { [string]$_.deviceName -ieq $Name }) if ($matchedDevices.Count -eq 0) { Write-Verbose -Message "No managed devices found with deviceName '$Name'." return [PSCustomObject[]]@() } # Return managed device objects with fields required by downstream callers. $matchedDevices | ForEach-Object -Process { [PSCustomObject]@{ id = $_.id deviceName = $_.deviceName userPrincipalName = $_.userPrincipalName manufacturer = $_.manufacturer model = $_.model operatingSystem = $_.operatingSystem serialNumber = $_.serialNumber enrolledByUserId = $_.enrolledByUserId complianceState = $_.complianceState lastSyncDateTime = $_.lastSyncDateTime } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByName] - Done" #endregion [functions] - [private] - [Resolve-IntuneDeviceByName] #region [functions] - [private] - [Resolve-IntuneDeviceByUser] Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByUser] - Importing" function Resolve-IntuneDeviceByUser { <# .SYNOPSIS Resolves one or more Intune managed devices by primary user UPN. .DESCRIPTION Queries Intune managed devices using the userPrincipalName filter. Performs case-insensitive exact match searching via OData filter. Returns all devices assigned to the specified user. .PARAMETER UserPrincipalName The UPN of the primary user to search for in Intune managed devices. .EXAMPLE Resolve-IntuneDeviceByUser -UserPrincipalName "jane.doe@contoso.com" Returns all managed device objects whose primary user is jane.doe@contoso.com. .INPUTS System.String .OUTPUTS PSCustomObject[] .NOTES Part of the Intune Device helper functions. Uses Microsoft Graph /beta endpoint. Requires DeviceManagementManagedDevices.Read.All scope. #> [OutputType([PSCustomObject[]])] [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = "The UPN of the primary user to resolve" )] [ValidateNotNullOrEmpty()] [string]$UserPrincipalName ) begin { $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' $select = 'id,deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' } process { # Escape only the string literal; keep OData filter syntax intact. $escapedUpn = $UserPrincipalName.Replace("'", "''") $filter = "userPrincipalName eq '$escapedUpn'" # Ordered fallback chain matching the BadRequest resilience pattern used elsewhere. $candidateUris = @( "$baseUri`?`$filter=$filter&`$select=$select", "$baseUri`?`$filter=$filter", "$baseUri`?`$select=$select", $baseUri ) $resp = $null $lastBadRequestError = $null foreach ($candidateUri in $candidateUris) { try { $resp = Invoke-GraphGet -Uri $candidateUri break } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'BadRequest|400') { $lastBadRequestError = $_ Write-Verbose -Message "Managed device query returned BadRequest for URI '$candidateUri'. Trying next fallback." continue } # Non-BadRequest errors are fatal; re-throw immediately. throw } } # All candidates failed with BadRequest; surface the last error. if ($null -eq $resp -and $null -ne $lastBadRequestError) { throw $lastBadRequestError } # Normalise response: unwrap .value collection or treat as single object. $devices = @() if ($null -ne $resp) { if ($null -ne $resp.value) { $devices = @($resp.value) } else { $devices = @($resp) } } # Apply exact match locally to handle unfiltered fallback responses. $matchedDevices = @($devices | Where-Object -FilterScript { [string]$_.userPrincipalName -ieq $UserPrincipalName }) if ($matchedDevices.Count -eq 0) { Write-Verbose -Message "No managed devices found for userPrincipalName '$UserPrincipalName'." return [PSCustomObject[]]@() } # Return managed device objects with fields required by downstream callers. $matchedDevices | ForEach-Object -Process { [PSCustomObject]@{ id = $_.id deviceName = $_.deviceName userPrincipalName = $_.userPrincipalName manufacturer = $_.manufacturer model = $_.model operatingSystem = $_.operatingSystem serialNumber = $_.serialNumber complianceState = $_.complianceState lastSyncDateTime = $_.lastSyncDateTime } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByUser] - Done" #endregion [functions] - [private] - [Resolve-IntuneDeviceByUser] Write-Debug "[$scriptName] - [functions] - [private] - Done" #endregion [functions] - [private] #region [functions] - [public] Write-Debug "[$scriptName] - [functions] - [public] - Processing folder" #region [functions] - [public] - [completers] Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Importing" Register-ArgumentCompleter -CommandName New-PSModuleTest -ParameterName Name -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters 'Alice', 'Bob', 'Charlie' | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Done" #endregion [functions] - [public] - [completers] #region [functions] - [public] - [Device] Write-Debug "[$scriptName] - [functions] - [public] - [Device] - Processing folder" #region [functions] - [public] - [Device] - [Get-IntuneDevice] Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDevice] - Importing" #Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' } function Get-IntuneDevice { <# .SYNOPSIS Retrieves Intune managed device details by DeviceId, DeviceName, or primary user UPN. .DESCRIPTION Queries Microsoft Graph (beta) for managed device details and returns a compact device summary. Supports lookup by managed device ID, by device name, or by the primary user's UPN. Requires an authenticated Graph session with appropriate scopes. Scopes (minimum): - DeviceManagementManagedDevices.Read.All .PARAMETER DeviceId The Intune managed device identifier (GUID). Parameter set: ById. .PARAMETER DeviceName One or more device names to resolve in Intune managed devices. Parameter set: ByName. If multiple devices share the same name, all matches are returned. Accepts multiple names via array or pipeline. .PARAMETER UserPrincipalName The UPN of the primary user whose devices should be returned. Parameter set: ByUser. If the user has multiple enrolled devices, all matches are returned. .EXAMPLE Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All" Get-IntuneDevice -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" Retrieves summary details for the specified managed device. .EXAMPLE Get-IntuneDevice -DeviceName PC-001 Resolves device by name and returns summary details for each matching managed device. .EXAMPLE Get-IntuneDevice -UserPrincipalName `jane.doe@contoso.com` Returns summary details for all managed devices whose primary user is `jane.doe@contoso.com`. .INPUTS System.String (DeviceId, DeviceName, or UserPrincipalName via pipeline/property name) .OUTPUTS PSCustomObject with the following properties - DeviceName (string) - PrimaryUser (string) - DeviceManufacturer (string) - DeviceModel (string) - OperatingSystem (string) - SerialNumber (string) - Compliance (string) - LastSyncDateTime (datetime) .NOTES Author: FHN & GitHub Copilot Uses /beta Graph endpoints for managed device properties. #> [OutputType([PSCustomObject])] [CmdletBinding(DefaultParameterSetName = 'ById', SupportsShouldProcess = $false)] param( [Parameter( ParameterSetName = 'ById', Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')] [Alias('Id', 'ManagedDeviceId')] [string]$DeviceId, [Parameter( ParameterSetName = 'ByName', Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true )] [ValidateNotNullOrEmpty()] [Alias('Name', 'ComputerName')] [string[]]$DeviceName, [Parameter( ParameterSetName = 'ByUser', Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true )] [ValidateNotNullOrEmpty()] [Alias('UPN', 'PrimaryUser')] [string]$UserPrincipalName ) begin { $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' # Request only fields needed for enrichment + output mapping. $select = 'deviceName,userPrincipalName,manufacturer,model,operatingSystem,serialNumber,complianceState,lastSyncDateTime' } process { switch ($PSCmdlet.ParameterSetName) { 'ById' { Write-Verbose -Message "Resolving managed device by id: $DeviceId" $uri = "$baseUri/$DeviceId?`$select=$select" try { $device = Invoke-GraphGet -Uri $uri } catch { $errorMessage = $_.Exception.Message # Distinguish 404 (not found) from network/auth failures. if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $exception = [Exception]::new("Managed device not found for id '$DeviceId': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceId ) $PSCmdlet.WriteError($errorRecord) return } $exception = [Exception]::new("Failed to resolve device id '$DeviceId': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $DeviceId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Graph returned null; invalid or removed device. if (-not $device) { $exception = [Exception]::new("Managed device not found for id '$DeviceId'.") $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceId ) $PSCmdlet.WriteError($errorRecord) return } # Map to public output contract. ConvertTo-IntuneDeviceSummary -Device $device } 'ByName' { # Iterate through each provided device name. foreach ($currentDeviceName in $DeviceName) { Write-Verbose -Message "Resolving managed device(s) by name: $currentDeviceName" try { $deviceSummaries = Resolve-IntuneDeviceByName -Name $currentDeviceName } catch { $errorMessage = $_.Exception.Message # Distinguish name resolution failure (no match) from actual Graph errors. if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $exception = [Exception]::new("Managed device not found for name '$currentDeviceName': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNameNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $currentDeviceName ) $PSCmdlet.WriteError($errorRecord) continue } $exception = [Exception]::new("Failed to resolve device name '$currentDeviceName': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNameLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $currentDeviceName ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Empty result set: name does not match any device. if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { $exception = [Exception]::new("Managed device not found for name '$currentDeviceName'.") $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceNameNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $currentDeviceName ) $PSCmdlet.WriteError($errorRecord) continue } # Resolve-IntuneDeviceByName already returns selected managed device fields. foreach ($device in $deviceSummaries) { if (-not $device) { continue } # Map to public output contract. ConvertTo-IntuneDeviceSummary -Device $device } } } 'ByUser' { Write-Verbose -Message "Resolving managed device(s) by user: $UserPrincipalName" try { $deviceSummaries = Resolve-IntuneDeviceByUser -UserPrincipalName $UserPrincipalName } catch { $errorMessage = $_.Exception.Message # Distinguish user not found from actual Graph errors. if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $exception = [Exception]::new("Managed device not found for user '$UserPrincipalName': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceUserNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $UserPrincipalName ) $PSCmdlet.WriteError($errorRecord) return } $exception = [Exception]::new("Failed to resolve devices for user '$UserPrincipalName': $errorMessage", $_.Exception) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceUserLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $UserPrincipalName ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Empty result set: user has no enrolled devices. if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { $exception = [Exception]::new("No managed devices found for user '$UserPrincipalName'.") $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DeviceUserNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $UserPrincipalName ) $PSCmdlet.WriteError($errorRecord) return } # Resolve-IntuneDeviceByUser already returns selected managed device fields. foreach ($device in $deviceSummaries) { if (-not $device) { continue } # Map to public output contract. ConvertTo-IntuneDeviceSummary -Device $device } } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDevice] - Done" #endregion [functions] - [public] - [Device] - [Get-IntuneDevice] #region [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] - Importing" #Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' } function Get-IntuneDeviceLogin { <# .SYNOPSIS Retrieves logged-on user info for an Intune-managed device by DeviceId, DeviceName, UserPrincipalName, or UserId. .DESCRIPTION Uses Microsoft Graph (beta) to read managed device metadata and the `usersLoggedOn` collection. When given a DeviceId, queries that specific device. When given a DeviceName, resolves one or more matching managed devices (`deviceName eq '<name>'`) and returns logon info for each match. When given a UserPrincipalName or UserId, searches all managed devices and returns only those where the specified user has logged in. Requires an authenticated Graph session with appropriate scopes. Scopes (minimum): - DeviceManagementManagedDevices.Read.All - User.Read.All .PARAMETER DeviceId The Intune managed device identifier (GUID). Parameter set: ById. .PARAMETER DeviceName The device name to resolve in Intune managed devices. Parameter set: ByName. If multiple devices share the same name, all matches are processed. .PARAMETER UserPrincipalName The user principal name (UPN) to search for across all managed devices. Parameter set: ByUserPrincipalName. Returns all devices where this user has logged in. .PARAMETER UserId The Entra ID user object identifier (GUID). Parameter set: ByUserId. Returns all devices where this user has logged in. .EXAMPLE Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All" Get-IntuneDeviceLogin -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" Gets logged-on user info for the specified device. .EXAMPLE Get-IntuneDeviceLogin -DeviceName PC-001 Resolves the device name and returns logged-on user info for the match. .EXAMPLE Get-IntuneDeviceLogin -UserPrincipalName "<UserPrincipalName>" Returns all devices where the specified user principal name has logged in. .EXAMPLE Get-IntuneDeviceLogin -UserId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" Returns all devices where the specified user (by ID) has logged in. .INPUTS System.String (DeviceId, DeviceName, UserPrincipalName, or UserId via pipeline/property name) .OUTPUTS PSCustomObject with the following properties - DeviceId (string) - DeviceName (string) - OperatingSystem (string) - UserId (string) - UserPrincipalName (string) - LastLogonDateTime (datetime) .NOTES Author: FHN & ChatGPT & GitHub Copilot - Uses /beta Graph endpoints because usersLoggedOn is exposed there. #> [OutputType([PSCustomObject])] [CmdletBinding(DefaultParameterSetName = 'ById', SupportsShouldProcess = $false)] param( # ById: DeviceId (GUID) [Parameter( ParameterSetName = 'ById', Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')] [Alias('Id', 'ManagedDeviceId')] [string]$DeviceId, # ByName: DeviceName (string) [Parameter( ParameterSetName = 'ByName', Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true )] [ValidateNotNullOrEmpty()] [Alias('Name', 'ComputerName')] [string]$DeviceName, # ByUserPrincipalName: UserPrincipalName (string) [Parameter( ParameterSetName = 'ByUserPrincipalName', Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [ValidateNotNullOrEmpty()] [Alias('UPN')] [string]$UserPrincipalName, # ByUserId: UserId (GUID) [Parameter( ParameterSetName = 'ByUserId', Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')] [string]$UserId ) begin { $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices' } process { switch ($PSCmdlet.ParameterSetName) { 'ById' { Write-Verbose -Message "Resolving usersLoggedOn for device id: $DeviceId" $uri = "$baseUri/$DeviceId" try { $device = Invoke-GraphGet -Uri $uri } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $Exception = [Exception]::new("Managed device not found for id '$DeviceId': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceId ) $PSCmdlet.WriteError($ErrorRecord) return } $Exception = [Exception]::new("Failed to resolve device id '$DeviceId': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $DeviceId ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if (-not $device) { $Exception = [Exception]::new("Managed device not found for id '$DeviceId'.") $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceId ) $PSCmdlet.WriteError($ErrorRecord) return } if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) { Write-Verbose -Message "No logged-on users found for device '$($device.deviceName)' ($DeviceId)." return } foreach ($entry in $device.usersLoggedOn) { $user = Resolve-EntraUserById -UserId $entry.userId [PSCustomObject]@{ DeviceName = $device.deviceName OperatingSystem = $device.operatingSystem UserPrincipalName = $user.userPrincipalName DeviceId = $device.id UserId = $entry.userId LastLogonDateTime = [datetime]$entry.lastLogOnDateTime } } } 'ByName' { Write-Verbose -Message "Resolving device(s) by name: $DeviceName" try { $deviceSummaries = Resolve-IntuneDeviceByName -Name $DeviceName } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $Exception = [Exception]::new("Managed device not found for name '$DeviceName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNameNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceName ) $PSCmdlet.WriteError($ErrorRecord) return } $Exception = [Exception]::new("Failed to resolve device name '$DeviceName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNameLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $DeviceName ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) { $Exception = [Exception]::new("Managed device not found for name '$DeviceName'.") $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNameNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $DeviceName ) $PSCmdlet.WriteError($ErrorRecord) return } if ($deviceSummaries.Count -gt 1) { Write-Verbose -Message "Multiple devices matched name '$DeviceName' ($($deviceSummaries.Count) matches). Returning results for all." } foreach ($summary in $deviceSummaries) { $uri = "$baseUri/$($summary.Id)" try { $device = Invoke-GraphGet -Uri $uri } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $summary.Id ) $PSCmdlet.WriteError($ErrorRecord) continue } $Exception = [Exception]::new("Failed to retrieve device id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceLookupFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $summary.Id ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if (-not $device) { $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName'.") $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'DeviceNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $summary.Id ) $PSCmdlet.WriteError($ErrorRecord) continue } if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) { Write-Verbose -Message "No logged-on users found for device '$($summary.DeviceName)' ($($summary.Id))." continue } foreach ($entry in $device.usersLoggedOn) { $user = Resolve-EntraUserById -UserId $entry.userId [PSCustomObject]@{ DeviceName = $device.deviceName OperatingSystem = $device.operatingSystem UserPrincipalName = $user.userPrincipalName DeviceId = $device.id UserId = $entry.userId LastLogonDateTime = [datetime]$entry.lastLogOnDateTime } } } } { $_ -in 'ByUserPrincipalName', 'ByUserId' } { # Resolve UPN to UserId if needed if ($PSCmdlet.ParameterSetName -eq 'ByUserPrincipalName') { Write-Verbose -Message "Resolving UserPrincipalName '$UserPrincipalName' to UserId" $userUri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName" try { $userObj = Invoke-GraphGet -Uri $userUri $targetUserId = $userObj.id Write-Verbose -Message "Resolved to UserId: $targetUserId" } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') { $Exception = [Exception]::new("Could not resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'UserResolutionFailed', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $UserPrincipalName ) $PSCmdlet.WriteError($ErrorRecord) return } $Exception = [Exception]::new("Failed to resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'UserResolutionGraphRequestFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $UserPrincipalName ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } } else { $targetUserId = $UserId } Write-Verbose -Message "Searching for devices where user '$targetUserId' has logged in" # Get all managed devices with usersLoggedOn property. # Invoke-GraphGet already handles pagination, so we query once and filter client-side. $uri = "$baseUri`?`$select=id,deviceName,operatingSystem,usersLoggedOn" $resp = Invoke-GraphGet -Uri $uri $allDevices = @() if ($null -ne $resp) { if ($null -ne $resp.value) { $allDevices = @($resp.value) } else { $allDevices = @($resp) } } if ($allDevices.Count -eq 0) { Write-Verbose -Message "No managed devices found." return } Write-Verbose -Message "Checking $($allDevices.Count) managed devices for user logons" $matchCount = 0 $seenResults = [System.Collections.Generic.HashSet[string]]::new() foreach ($device in $allDevices) { # Check if target user is in the usersLoggedOn collection $userLogon = $device.usersLoggedOn | Where-Object -FilterScript { $_.userId -eq $targetUserId } if ($userLogon) { $latestUserLogon = $userLogon | Sort-Object -Property lastLogOnDateTime -Descending | Select-Object -First 1 $logonTimestamp = [datetime]$latestUserLogon.lastLogOnDateTime $resultKey = "{0}|{1}|{2}" -f $device.id, $targetUserId, $logonTimestamp.ToString('o') if (-not $seenResults.Add($resultKey)) { continue } $matchCount++ $user = Resolve-EntraUserById -UserId $targetUserId [PSCustomObject]@{ DeviceName = $device.deviceName OperatingSystem = $device.operatingSystem UserPrincipalName = $user.userPrincipalName DeviceId = $device.id UserId = $targetUserId LastLogonDateTime = $logonTimestamp } } } if ($matchCount -eq 0) { Write-Verbose -Message "No devices found where user '$targetUserId' has logged in." } } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] - Done" #endregion [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] Write-Debug "[$scriptName] - [functions] - [public] - [Device] - Done" #endregion [functions] - [public] - [Device] #region [functions] - [public] - [Remediation] Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - Processing folder" #region [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] - Importing" function Get-IntuneRemediationDeviceStatus { <# .SYNOPSIS Retrieves per-device run state and pre/post remediation output for a specific Intune proactive remediation. .DESCRIPTION Queries Microsoft Graph (beta) for the device run states of a proactive remediation script (device health script). Returns one row per device, including the detection and remediation states and the captured pre/post-remediation detection script output. Requires an authenticated Graph session with appropriate scopes. Scopes (minimum): - DeviceManagementConfiguration.Read.All .PARAMETER Name The display name of the remediation script. Supports wildcards (*). When multiple scripts match, all are processed. Parameter set: ByName. .PARAMETER Id The ID (GUID) of the device health script (remediation) to query. Parameter set: ById. .PARAMETER ConvertPreRemediationOutput Parses PreRemediationOutput as JSON and adds a PreRemediationData property when valid JSON is present. .PARAMETER ConvertPostRemediationOutput Parses PostRemediationOutput as JSON and adds a PostRemediationData property when valid JSON is present. .EXAMPLE Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All" Get-IntuneRemediationDeviceStatus -Name "BitLocker*" Returns per-device run states for all remediations whose name starts with "BitLocker". .EXAMPLE Get-IntuneRemediationDeviceStatus -Id "f1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a" Returns per-device run states for the remediation with the specified ID. .EXAMPLE Get-IntuneRemediationSummary | Where-Object WithIssues -gt 0 | Get-IntuneRemediationDeviceStatus Pipes remediations that have devices with issues into this cmdlet to get device-level detail. .EXAMPLE Get-IntuneRemediationDeviceStatus -Name 'Win | Device | All | Detection | Secure Boot Status' -ConvertPreRemediationOutput | Where-Object { $null -ne $_.PreRemediationData } Parses valid JSON found in PreRemediationOutput and exposes it as PreRemediationData. .EXAMPLE Get-IntuneRemediationDeviceStatus -Name 'Win | Device | All | Detection | Secure Boot Status' -ConvertPreRemediationOutput -ConvertPostRemediationOutput | Select-Object DeviceName, DetectionState, RemediationState, PreRemediationData, PostRemediationData Parses valid JSON from both remediation output fields and selects the parsed data alongside device state. .INPUTS System.String (Name or Id via pipeline by property name) .OUTPUTS PSCustomObject with the following properties - RemediationName (string) : Display name of the remediation script - RemediationId (string) : GUID of the device health script - DeviceId (string) : Managed device ID - DeviceName (string) : Managed device display name - UserPrincipalName (string) : Primary user UPN of the device - LastStateUpdate (datetime / null): When the run state was last updated - DetectionState (string) : Outcome of the last detection script run - RemediationState (string) : Outcome of the last remediation script run - PreRemediationOutput (string) : stdout captured before remediation ran - PostRemediationOutput (string) : stdout captured after remediation ran - PreRemediationData (object/null) : Parsed JSON from PreRemediationOutput when -ConvertPreRemediationOutput is used - PostRemediationData (object/null): Parsed JSON from PostRemediationOutput when -ConvertPostRemediationOutput is used - DetectionOutput (string) : Detection-only script stdout - PreRemediationError (string) : stderr captured before remediation ran - RemediationError (string) : stderr from the remediation script - DetectionError (string) : stderr from the detection script .NOTES Author: FHN & GitHub Copilot - Uses /beta Graph endpoints. - Expands managedDevice to include DeviceName and UserPrincipalName inline. #> [OutputType([PSCustomObject])] [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess = $false)] param( [Parameter( ParameterSetName = 'ByName', Mandatory = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = 'Display name (supports wildcards) of the remediation script' )] [ValidateNotNullOrEmpty()] [Alias('RemediationName')] [string]$Name, [Parameter( ParameterSetName = 'ById', Mandatory = $true, ValueFromPipelineByPropertyName = $true, HelpMessage = 'GUID of the device health / remediation script' )] [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')] [Alias('RemediationId')] [string]$Id, [Parameter(HelpMessage = 'Parse pre-remediation output as JSON and add PreRemediationData')] [switch]$ConvertPreRemediationOutput, [Parameter(HelpMessage = 'Parse post-remediation output as JSON and add PostRemediationData')] [switch]$ConvertPostRemediationOutput ) begin { # Expand managedDevice so device identity fields are available in each run-state row. $expandQuery = '$expand=managedDevice($select=id,deviceName,userPrincipalName)' } process { # ------------------------------------------------------------------ # # 1. Resolve the target script(s) when called by name # # ------------------------------------------------------------------ # $targetScripts = @() if ($PSCmdlet.ParameterSetName -eq 'ById') { $targetScripts += [PSCustomObject]@{ id = $Id displayName = $Id # will be overwritten once we fetch the real name } # Resolve display name for friendlier output while still accepting direct ID input. try { $scriptDetailUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($Id)?`$select=id,displayName" $scriptDetail = Invoke-GraphGet -Uri $scriptDetailUri if ($null -ne $scriptDetail -and -not [string]::IsNullOrWhiteSpace($scriptDetail.displayName)) { $targetScripts[0] = [PSCustomObject]@{ id = $Id displayName = [string]$scriptDetail.displayName } } } catch { Write-Warning -Message "Could not fetch display name for remediation '$Id': $($_.Exception.Message)" } } else { # ByName – list all scripts then filter $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' try { $listResponse = Invoke-GraphGet -Uri $listUri } catch { if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') { $Exception = [Exception]::new("Failed to list proactive remediations: access denied. Ensure your account has an Intune role with permission to read Device configurations, then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'RemediationListAccessDenied', [System.Management.Automation.ErrorCategory]::PermissionDenied, $listUri ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } $Exception = [Exception]::new("Failed to list proactive remediations: $($_.Exception.Message)", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'RemediationListFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $listUri ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } # Normalize payload shape before wildcard name matching. $allScripts = @() if ($null -ne $listResponse) { if ($null -ne $listResponse.value) { $allScripts = @($listResponse.value) } else { $allScripts = @($listResponse) } } $targetScripts = @($allScripts | Where-Object { $_.displayName -like $Name }) if ($targetScripts.Count -eq 0) { $Exception = [Exception]::new("No remediation script found matching name '$Name'.") $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'RemediationNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $Name ) $PSCmdlet.WriteError($ErrorRecord) return } } # ------------------------------------------------------------------ # # 2. For each matched script, retrieve per-device run states # # ------------------------------------------------------------------ # foreach ($script in $targetScripts) { $remediationName = [string]$script.displayName $remediationId = [string]$script.id Write-Verbose -Message "Retrieving device run states for remediation '$remediationName' ($remediationId)" $runStatesUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$remediationId/deviceRunStates?$expandQuery" try { $runStatesResponse = Invoke-GraphGet -Uri $runStatesUri } catch { Write-Warning -Message "Failed to retrieve device run states for '$remediationName' ($remediationId): $($_.Exception.Message)" continue } # Normalize run-state list: handle both .value and direct response shapes. $runStates = @() if ($null -ne $runStatesResponse) { if ($null -ne $runStatesResponse.value) { $runStates = @($runStatesResponse.value) } else { $runStates = @($runStatesResponse) } } if ($runStates.Count -eq 0) { Write-Verbose -Message "No device run states found for '$remediationName'." continue } foreach ($state in $runStates) { # Resolve device fields from the expanded managedDevice object $device = $null $deviceId = $null $deviceName = $null $upn = $null if ($null -ne $state.managedDevice) { $device = $state.managedDevice $deviceId = [string]$device.id $deviceName = [string]$device.deviceName $upn = [string]$device.userPrincipalName } # Resolve last-state timestamp $lastUpdate = $null $rawDate = $state.lastStateUpdateDateTime if ($null -ne $rawDate -and -not [string]::IsNullOrWhiteSpace([string]$rawDate)) { # Guard output typing: invalid timestamps should not break object emission. try { $lastUpdate = [datetime]$rawDate } catch { $lastUpdate = $null } } # Capture the raw script output once so optional JSON parsing stays local. $preRemediationOutput = [string]$state.preRemediationDetectionScriptOutput $postRemediationOutput = [string]$state.postRemediationDetectionScriptOutput # Build the output object in-order and extend it only when JSON parsing is requested. $outputObject = [ordered]@{ RemediationName = $remediationName RemediationId = $remediationId DeviceId = $deviceId DeviceName = $deviceName UserPrincipalName = $upn LastStateUpdate = $lastUpdate DetectionState = [string]$state.detectionState RemediationState = [string]$state.remediationState PreRemediationOutput = $preRemediationOutput PostRemediationOutput = $postRemediationOutput DetectionOutput = [string]$state.detectionScriptOutput PreRemediationError = [string]$state.preRemediationDetectionScriptError RemediationError = [string]$state.remediationScriptError DetectionError = [string]$state.detectionScriptError } if ($ConvertPreRemediationOutput.IsPresent) { $outputObject.PreRemediationData = $null if (-not [string]::IsNullOrWhiteSpace($preRemediationOutput) -and $preRemediationOutput.Trim().StartsWith('{')) { try { $outputObject.PreRemediationData = $preRemediationOutput | ConvertFrom-Json -ErrorAction Stop } catch { Write-Verbose -Message "Failed to parse pre-remediation JSON for device '$deviceName'." } } } if ($ConvertPostRemediationOutput.IsPresent) { $outputObject.PostRemediationData = $null if (-not [string]::IsNullOrWhiteSpace($postRemediationOutput) -and $postRemediationOutput.Trim().StartsWith('{')) { try { $outputObject.PostRemediationData = $postRemediationOutput | ConvertFrom-Json -ErrorAction Stop } catch { Write-Verbose -Message "Failed to parse post-remediation JSON for device '$deviceName'." } } } # Cast Graph fields to predictable output types for downstream filtering/export. [PSCustomObject]$outputObject } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] - Done" #endregion [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] #region [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] - Importing" function Get-IntuneRemediationSummary { <# .SYNOPSIS Retrieves remediation summary statistics for all Intune proactive remediations. .DESCRIPTION Queries Microsoft Graph (beta) for all device health scripts (proactive remediations) and returns one summary row per remediation, including status and key issue counters. Requires an authenticated Graph session with appropriate scopes. Scopes (minimum): - DeviceManagementConfiguration.Read.All .EXAMPLE Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All" Get-IntuneRemediationSummary Returns one row per remediation with status and issue/remediation counters. .OUTPUTS PSCustomObject with the following properties - Name (string) - Status (string) - WithoutIssues (int) - WithIssues (int) - IssueFixed (int) - IssueRecurred (int) - TotalRemediated (int) .NOTES Author: FHN & GitHub Copilot - Uses /beta Graph endpoints. #> [OutputType([PSCustomObject])] [CmdletBinding(SupportsShouldProcess = $false)] param() begin { # Query all proactive remediations once and process summaries per script. $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' } process { Write-Verbose -Message 'Retrieving proactive remediations from Microsoft Graph' try { $scriptsResponse = Invoke-GraphGet -Uri $listUri } catch { if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') { $Exception = [Exception]::new("Failed to retrieve proactive remediations: access denied by Intune RBAC or tenant policy. Ensure your account has an Intune role with permission to read Device configurations (Endpoint Analytics/Remediations), then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'RemediationListAccessDenied', [System.Management.Automation.ErrorCategory]::PermissionDenied, $listUri ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } $Exception = [Exception]::new("Failed to retrieve proactive remediations: $($_.Exception.Message)", $_.Exception) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, 'RemediationListFailed', [System.Management.Automation.ErrorCategory]::NotSpecified, $listUri ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } # Normalize payload shape: Graph may return either an object with .value or a direct object/array. $scripts = @() if ($null -ne $scriptsResponse) { if ($null -ne $scriptsResponse.value) { $scripts = @($scriptsResponse.value) } else { $scripts = @($scriptsResponse) } } foreach ($script in $scripts) { if ($null -eq $script.id) { continue } $scriptName = [string]$script.displayName if ([string]::IsNullOrWhiteSpace($scriptName)) { $scriptName = [string](Get-FirstPropertyValue -InputObject $script -PropertyNames @('displayName', 'name') -DefaultValue $script.id) } Write-Verbose -Message "Processing remediation summary for '$scriptName'" $summaryUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/runSummary" try { $summaryResponse = Invoke-GraphGet -Uri $summaryUri } catch { Write-Warning -Message "Failed to retrieve run summary for '$scriptName' ($($script.id)): $($_.Exception.Message)" continue } # Normalize run summary root before probing nested alternatives. $summary = $summaryResponse if ($null -ne $summaryResponse.value) { if ($summaryResponse.value -is [array]) { $summary = $summaryResponse.value | Select-Object -First 1 } else { $summary = $summaryResponse.value } } if ($null -eq $summary) { $summary = $summaryResponse } # Some tenants return nested shapes for run summary payloads. # Resolve to the first object that actually contains known summary counters. $summaryCandidates = @() if ($null -ne $summary) { $summaryCandidates += $summary if ($null -ne $summary.runSummary) { $summaryCandidates += $summary.runSummary } } if ($null -ne $summaryResponse) { $summaryCandidates += $summaryResponse if ($null -ne $summaryResponse.runSummary) { $summaryCandidates += $summaryResponse.runSummary } if ($null -ne $summaryResponse.value -and $null -ne $summaryResponse.value.runSummary) { $summaryCandidates += $summaryResponse.value.runSummary } } if ($null -ne $summary.value) { $summaryCandidates += $summary.value if ($null -ne $summary.value.runSummary) { $summaryCandidates += $summary.value.runSummary } } $resolvedSummary = $null foreach ($candidate in $summaryCandidates) { if ($null -eq $candidate) { continue } $candidateNoIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( 'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues' ) -DefaultValue $null $candidateWithIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( 'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues' ) -DefaultValue $null $candidateFixed = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( 'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount' ) -DefaultValue $null $candidateRecurred = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( 'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount' ) -DefaultValue $null $candidateTotalRemediated = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @( 'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated' ) -DefaultValue $null if ($null -ne $candidateNoIssue -or $null -ne $candidateWithIssue -or $null -ne $candidateFixed -or $null -ne $candidateRecurred -or $null -ne $candidateTotalRemediated) { $resolvedSummary = $candidate break } } if ($null -ne $resolvedSummary) { $summary = $resolvedSummary } # Prefer status from script metadata; fall back to summary payload when missing. $status = Get-FirstPropertyValue -InputObject $script -PropertyNames @( 'status' ) -DefaultValue $null if ([string]::IsNullOrWhiteSpace([string]$status)) { $status = Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'status', 'remediationStatus', 'scriptExecutionStatus' ) -DefaultValue $null } # Counter aliases vary by tenant/API shape, so probe multiple known names. $withoutIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues' ) -DefaultValue 0) $withIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues' ) -DefaultValue 0) $issueFixed = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount' ) -DefaultValue 0) $issueRecurred = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount' ) -DefaultValue 0) $totalRemediated = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated' ) -DefaultValue 0) # If summary counters are empty, derive best-effort values from remediation history. if ($withoutIssues -eq 0 -and $withIssues -eq 0 -and $issueFixed -eq 0 -and $issueRecurred -eq 0 -and $totalRemediated -eq 0) { $historyUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/getRemediationHistory" try { $historyResponse = Invoke-GraphGet -Uri $historyUri $historyRoot = $historyResponse if ($null -ne $historyResponse.value) { $historyRoot = $historyResponse.value } $historyData = @() if ($null -ne $historyRoot.historyData) { $historyData = @($historyRoot.historyData) } elseif ($historyRoot -is [array]) { $historyData = @($historyRoot) } elseif ($null -ne $historyRoot.date) { $historyData = @($historyRoot) } $latestHistory = $null if ($historyData.Count -gt 0) { $latestHistory = $historyData | Sort-Object -Property date -Descending | Select-Object -First 1 } if ($null -ne $latestHistory) { $historyNoIssue = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0) $historyDetectFailed = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0) $historyRemediated = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0) if ($historyNoIssue -eq 0 -and $historyDetectFailed -eq 0 -and $historyRemediated -eq 0 -and $historyData.Count -gt 0) { $historyNoIssue = ($historyData | ForEach-Object { [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0) } | Measure-Object -Maximum).Maximum $historyDetectFailed = ($historyData | ForEach-Object { [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0) } | Measure-Object -Maximum).Maximum $historyRemediated = ($historyData | ForEach-Object { [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0) } | Measure-Object -Sum).Sum } $withoutIssues = $historyNoIssue $issueFixed = $historyRemediated if ($totalRemediated -eq 0) { $totalRemediated = $historyRemediated } if ($withIssues -eq 0) { $withIssues = $historyRemediated + $historyDetectFailed } } if ($null -ne $historyRoot.lastModifiedDateTime -and [string]::IsNullOrWhiteSpace([string]$status)) { $status = 'Completed' } } catch { Write-Verbose -Message "No remediation history fallback available for '$scriptName' ($($script.id)): $($_.Exception.Message)" } } # Resolve a display-friendly status when no explicit status was returned. if ([string]::IsNullOrWhiteSpace([string]$status)) { $pendingCount = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'detectionScriptPendingDeviceCount' ) -DefaultValue 0) $lastRun = Get-FirstPropertyValue -InputObject $summary -PropertyNames @( 'lastScriptRunDateTime' ) -DefaultValue $null if ($pendingCount -gt 0) { $status = 'Pending' } elseif ($null -ne $lastRun -and -not [string]::IsNullOrWhiteSpace([string]$lastRun)) { $status = 'Completed' } else { $status = 'Unknown' } } [PSCustomObject]@{ Name = [string]$scriptName Status = [string]$status WithoutIssues = $withoutIssues WithIssues = $withIssues IssueFixed = $issueFixed IssueRecurred = $issueRecurred TotalRemediated = $totalRemediated } } } # Process } # Cmdlet Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] - Done" #endregion [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - Done" #endregion [functions] - [public] - [Remediation] Write-Debug "[$scriptName] - [functions] - [public] - Done" #endregion [functions] - [public] #region Member exporter $exports = @{ Alias = '*' Cmdlet = '' Function = @( 'Get-IntuneDevice' 'Get-IntuneDeviceLogin' 'Get-IntuneRemediationDeviceStatus' 'Get-IntuneRemediationSummary' ) Variable = '' } Export-ModuleMember @exports #endregion Member exporter |