IntuneOperator.psm1
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidAssignmentToAutomaticVariable', 'IsWindows', Justification = 'IsWindows doesnt exist in PS5.1' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'IsWindows', Justification = 'IsWindows doesnt exist in PS5.1' )] [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" if ($PSEdition -eq 'Desktop') { $IsWindows = $true } #region [init] Write-Debug "[$scriptName] - [init] - Processing folder" #region [init] - [initializer] Write-Debug "[$scriptName] - [init] - [initializer] - Importing" #Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' } Write-Debug "[$scriptName] - [init] - [initializer] - Done" #endregion [init] - [initializer] Write-Debug "[$scriptName] - [init] - Done" #endregion [init] #region [functions] - [private] Write-Debug "[$scriptName] - [functions] - [private] - Processing folder" #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 { # deviceName is case-insensitive in OData. Exact match. $encoded = [uri]::EscapeDataString("deviceName eq '$Name'") $uri = "$baseUri`?`$filter=$encoded&`$select=id,deviceName" $resp = Invoke-GraphGet -Uri $uri if ($null -eq $resp.value -or $resp.value.Count -eq 0) { Write-Verbose -Message "No managed devices found with deviceName '$Name'." return [PSCustomObject[]]@() } # Return PSCustomObject with Id and DeviceName $resp.value | ForEach-Object -Process { [PSCustomObject]@{ Id = $_.id DeviceName = $_.deviceName } } } } Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByName] - Done" #endregion [functions] - [private] - [Resolve-IntuneDeviceByName] 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-IntuneDeviceLogin] Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] - Importing" 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) - 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 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 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,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 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] Write-Debug "[$scriptName] - [functions] - [public] - Done" #endregion [functions] - [public] #region Member exporter $exports = @{ Alias = '*' Cmdlet = '' Function = 'Get-IntuneDeviceLogin' Variable = '' } Export-ModuleMember @exports #endregion Member exporter |