Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GraphEssentials.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Description = 'GraphEssentials is a PowerShell module that helps with Office 365 / Azure AD using mostly Graph'
FunctionsToExport = @('Disable-MyDevice', 'Get-MgToken', 'Get-MyApp', 'Get-MyAppCredentials', 'Get-MyConditionalAccess', 'Get-MyDefenderDeploymentKey', 'Get-MyDefenderHealthIssues', 'Get-MyDefenderSecureScore', 'Get-MyDefenderSecureScoreProfile', 'Get-MyDefenderSensor', 'Get-MyDefenderSummary', 'Get-MyDevice', 'Get-MyDeviceIntune', 'Get-MyGuest', 'Get-MyLicense', 'Get-MyRole', 'Get-MyRoleHistory', 'Get-MyRoleUsers', 'Get-MyTeam', 'Get-MyTenantName', 'Get-MyUsageReports', 'Get-MyUser', 'Get-MyUserAuthentication', 'Invoke-MyDeviceRetire', 'Invoke-MyGraphEssentials', 'Invoke-MyGraphUsageReports', 'New-MyApp', 'New-MyAppCredentials', 'Register-FIDO2Key', 'Remove-MyAppCredentials', 'Remove-MyAutopilotDevice', 'Remove-MyDevice', 'Remove-MyDeviceIntuneRecord', 'Send-MyApp', 'Show-MyApp', 'Show-MyConditionalAccess', 'Show-MyDefender', 'Show-MyRole', 'Show-MyUserAuthentication')
GUID = '75ef812f-6d8e-4898-81bb-8029e0560ef3'
ModuleVersion = '0.0.61'
ModuleVersion = '0.0.62'
PowerShellVersion = '5.1'
PrivateData = @{
PSData = @{
Expand Down
111 changes: 111 additions & 0 deletions Private/Get-GraphEssentialsPagedInventory.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
function Get-GraphEssentialsPagedInventory {
<#
.SYNOPSIS
Reads a Graph collection one page at a time with bounded page retries.

.DESCRIPTION
Keeps only the current raw page in memory and retries its URL after a transient
failure. Callers must buffer their final inventory until this function finishes,
because an incomplete collection must never be used for cleanup decisions.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Uri,

[ValidateRange(1, 10)]
[int] $MaxPageAttempts = 3
)

$pageUri = $Uri
$pageNumber = 0
$seenPageUris = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
while ($pageUri) {
if (-not $seenPageUris.Add($pageUri)) {
throw "Graph inventory returned a repeated page URL after page $pageNumber."
}
$pageNumber++
$attempt = 0
while ($true) {
$attempt++
try {
$response = Invoke-MgGraphRequest -Method GET -Uri $pageUri -OutputType PSObject -ErrorAction Stop
break
} catch {
$errorRecord = $_
$statusCode = $null
$httpResponse = $null
$transportFailure = $false
$exceptionMessages = [System.Collections.Generic.List[string]]::new()
$exception = $errorRecord.Exception
while ($exception) {
if ($exception.Message) {
$exceptionMessages.Add($exception.Message)
}
if (-not $httpResponse -and $exception.Response) {
$httpResponse = $exception.Response
}
if ($exception.GetType().FullName -in @(
'System.Net.Http.HttpRequestException', 'System.IO.IOException',
'System.Net.Sockets.SocketException', 'System.Threading.Tasks.TaskCanceledException'
)) {
$transportFailure = $true
}
$exception = $exception.InnerException
}
if ($httpResponse -and $httpResponse.StatusCode) {
$statusCode = [int] $httpResponse.StatusCode
}
$message = $exceptionMessages -join ' --> '
$transient = if ($null -ne $statusCode) {
$statusCode -in @(408, 429, 500, 502, 503, 504)
} else {
$transportFailure -or $message -match '(?i)timed?\s*out|timeout|cancell?ed.*300 seconds|connection.*(closed|reset)|transport stream|premature EOF'
}

if (-not $transient -or $attempt -ge $MaxPageAttempts) {
throw "Graph inventory page $pageNumber failed after $attempt attempt(s): $message"
}

$delaySeconds = [Math]::Min(30, [int] [Math]::Pow(2, $attempt - 1))
if ($null -ne $statusCode -and $httpResponse.Headers) {
$headers = $httpResponse.Headers
$retryAfter = $null
if ($headers.GetType().FullName -eq 'System.Net.Http.Headers.HttpResponseHeaders') {
if ($headers.RetryAfter) {
$retryAfter = [string] $headers.RetryAfter
}
} elseif ($headers -is [System.Collections.IDictionary]) {
$retryAfter = @($headers['Retry-After'])[0]
} else {
$retryAfter = $headers.'Retry-After'
}
$retryAfterSeconds = 0
if ($retryAfter -and [int]::TryParse([string] $retryAfter, [ref] $retryAfterSeconds)) {
$delaySeconds = [Math]::Max(1, $retryAfterSeconds)
} elseif ($retryAfter) {
$retryAt = [DateTimeOffset]::MinValue
if ([DateTimeOffset]::TryParse([string] $retryAfter, [ref] $retryAt)) {
$delaySeconds = [Math]::Max(1, [int] [Math]::Ceiling(($retryAt - [DateTimeOffset]::UtcNow).TotalSeconds))
}
}
if ($delaySeconds -gt 3600) {
throw "Graph inventory page $pageNumber requested a retry after $delaySeconds seconds; this run cannot complete within the retry limit."
}
}
Write-Verbose "Graph inventory page $pageNumber failed ($message). Retrying in $delaySeconds seconds."
Start-Sleep -Seconds $delaySeconds
}
}

if ($null -eq $response -or $null -eq $response.value) {
throw "Graph inventory page $pageNumber did not contain a value collection."
}
foreach ($item in $response.value) {
if ($null -ne $item) {
$item
}
}
$pageUri = $response.'@odata.nextLink'
}
}
158 changes: 124 additions & 34 deletions Public/Get-MyDevice.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@
.PARAMETER Synchronized
Returns only synchronized devices when specified (OnPremisesSyncEnabled is true).

.PARAMETER IncludeAutopilotInventory
When specified, enriches devices with Windows Autopilot identity metadata.
.PARAMETER IncludeAutopilotInventory
When specified, enriches devices with Windows Autopilot identity metadata.

.PARAMETER PropertySet
Full retains the complete device information. Lifecycle omits registered owners.
Computer returns only the dates, identifiers, and owner fields needed for computer
inventory correlation. Computer requests retry individual Graph pages, so a failed
page does not restart a large inventory. Owner continuations are read when present;
a possibly truncated owner expansion makes the inventory fail.

.EXAMPLE
Get-MyDevice
Expand All @@ -35,23 +42,33 @@
param(
[ValidateSet('Hybrid AzureAD', 'AzureAD joined', 'AzureAD registered', 'Not available')][string[]] $Type,
[switch] $Synchronized,
[switch] $IncludeAutopilotInventory
)

$TrustTypes = @{
[switch] $IncludeAutopilotInventory,
[ValidateSet('Full', 'Lifecycle', 'Computer')]
[string] $PropertySet = 'Full'
)

if ($PropertySet -eq 'Computer' -and $IncludeAutopilotInventory) {
throw 'Computer property set does not include Autopilot information.'
}

$TrustTypes = @{
'ServerAD' = 'Hybrid AzureAD'
'AzureAD' = 'AzureAD joined'
'Workplace' = 'AzureAD registered'
}

$Today = Get-Date
$Properties = @(
'accountEnabled', 'approximateLastSignInDateTime', 'deviceId', 'deviceOwnership',
$FullProperties = @(
'accountEnabled', 'approximateLastSignInDateTime', 'deviceId', 'deviceOwnership',
'displayName', 'enrollmentType', 'id', 'isCompliant', 'isManaged', 'managementType',
'manufacturer', 'model', 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled',
'operatingSystem', 'operatingSystemVersion', 'profileType', 'registrationDateTime',
'trustType'
)
'trustType'
)
$ComputerProperties = @(
'approximateLastSignInDateTime', 'deviceId', 'displayName', 'id',
'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'trustType'
)
$AutopilotLookup = $null
if ($IncludeAutopilotInventory) {
$AutopilotLookup = Get-GraphEssentialsAutopilotLookup
Expand All @@ -60,7 +77,19 @@
$DeviceCache = [System.Collections.Generic.List[object]]::new()
$NormalizedDevices = [System.Collections.Generic.List[object]]::new()
try {
Get-MgDevice -All -Property $Properties -ExpandProperty RegisteredOwners -ErrorAction Stop | ForEach-Object {
$getDevices = if ($PropertySet -eq 'Computer') {
$query = '/v1.0/devices?$select=' + ($ComputerProperties -join ',') + '&$top=200'
if ($Synchronized) {
$query += '&$filter=onPremisesSyncEnabled%20eq%20true'
}
$query += '&$expand=registeredOwners($select=id,displayName,userPrincipalName,accountEnabled)'
Comment thread
PrzemyslawKlys marked this conversation as resolved.
{ Get-GraphEssentialsPagedInventory -Uri $query }
} elseif ($PropertySet -eq 'Lifecycle') {
{ Get-MgDevice -All -Property $FullProperties -ErrorAction Stop }
} else {
{ Get-MgDevice -All -Property $FullProperties -ExpandProperty RegisteredOwners -ErrorAction Stop }
}
& $getDevices | ForEach-Object {
$Device = $_
if ($Device.DeviceId) {
$DeviceCache.Add([PSCustomObject] @{
Expand All @@ -85,33 +114,92 @@
return
}

if ($Device.ApproximateLastSignInDateTime) {
$LastSeenDays = [math]::Floor((New-TimeSpan -Start $Device.ApproximateLastSignInDateTime -End $Today).TotalDays)
if ($Device.ApproximateLastSignInDateTime) {
$lastSeenStart = if ($Device.ApproximateLastSignInDateTime -is [DateTimeOffset]) { $Device.ApproximateLastSignInDateTime.UtcDateTime } else { $Device.ApproximateLastSignInDateTime }
$LastSeenDays = [math]::Floor((New-TimeSpan -Start $lastSeenStart -End $Today).TotalDays)
}
else {
$LastSeenDays = $null
}
if ($Device.OnPremisesLastSyncDateTime) {
$LastSynchronizedDays = [math]::Floor((New-TimeSpan -Start $Device.OnPremisesLastSyncDateTime -End $Today).TotalDays)
if ($Device.OnPremisesLastSyncDateTime) {
$lastSyncStart = if ($Device.OnPremisesLastSyncDateTime -is [DateTimeOffset]) { $Device.OnPremisesLastSyncDateTime.UtcDateTime } else { $Device.OnPremisesLastSyncDateTime }
$LastSynchronizedDays = [math]::Floor((New-TimeSpan -Start $lastSyncStart -End $Today).TotalDays)
}
else {
$LastSynchronizedDays = $null
}

$OwnerDisplayName = [System.Collections.Generic.List[string]]::new()
$OwnerEnabled = [System.Collections.Generic.List[string]]::new()
$OwnerUserPrincipalName = [System.Collections.Generic.List[string]]::new()
foreach ($Owner in $Device.RegisteredOwners) {
if ($Owner.AdditionalProperties.displayName) {
$OwnerDisplayName.Add($Owner.AdditionalProperties.displayName)
}
if ($null -ne $Owner.AdditionalProperties.accountEnabled) {
$OwnerEnabled.Add([string] $Owner.AdditionalProperties.accountEnabled)
$OwnerDisplayName = [System.Collections.Generic.List[string]]::new()
$OwnerEnabled = [System.Collections.Generic.List[string]]::new()
$OwnerUserPrincipalName = [System.Collections.Generic.List[string]]::new()
$OwnerCount = 0
if ($PropertySet -eq 'Computer' -and
(($null -eq $Device.PSObject.Properties['registeredOwners'] -and
-not ($Device -is [System.Collections.IDictionary] -and $Device.Contains('registeredOwners'))) -or
$null -eq $Device.RegisteredOwners)) {
throw "Graph omitted registeredOwners for device '$($Device.Id)'."
}
$RegisteredOwners = $Device.RegisteredOwners
if ($PropertySet -eq 'Computer') {
$ownerNextLink = $Device.'registeredOwners@odata.nextLink'
if ($ownerNextLink) {
$RegisteredOwners = [System.Collections.Generic.List[object]]::new()
foreach ($Owner in $Device.RegisteredOwners) {
if ($null -ne $Owner) {
$RegisteredOwners.Add($Owner)
}
}
foreach ($Owner in (Get-GraphEssentialsPagedInventory -Uri $ownerNextLink)) {
$RegisteredOwners.Add($Owner)
}
} elseif ($Device.RegisteredOwners.Count -ge 20) {
throw "Graph may have truncated registeredOwners for device '$($Device.Id)'."
}
}
foreach ($Owner in $RegisteredOwners) {
if ($null -eq $Owner) {
continue
}
$OwnerCount++
$ownerProperties = if ($Owner.AdditionalProperties) { $Owner.AdditionalProperties } else { $Owner }
if ($PropertySet -eq 'Computer') {
$hasOwnerStatus = $null -ne $ownerProperties.PSObject.Properties['accountEnabled'] -or
($ownerProperties -is [System.Collections.IDictionary] -and $ownerProperties.Contains('accountEnabled'))
if (-not $hasOwnerStatus -or $ownerProperties.accountEnabled -isnot [bool]) {
throw "Graph omitted a Boolean accountEnabled for a registered owner of device '$($Device.Id)'."
}
}
if ($ownerProperties.displayName) {
$OwnerDisplayName.Add($ownerProperties.displayName)
}
if ($null -ne $ownerProperties.accountEnabled) {
$OwnerEnabled.Add([string] $ownerProperties.accountEnabled)
}
if ($ownerProperties.userPrincipalName) {
$OwnerUserPrincipalName.Add($ownerProperties.userPrincipalName)
}
if ($Owner.AdditionalProperties.userPrincipalName) {
$OwnerUserPrincipalName.Add($Owner.AdditionalProperties.userPrincipalName)
}
}
}

if ($PropertySet -eq 'Computer') {
$lastSeen = if ($Device.ApproximateLastSignInDateTime) { [DateTimeOffset] $Device.ApproximateLastSignInDateTime } else { $null }
$lastSynchronized = if ($Device.OnPremisesLastSyncDateTime) { [DateTimeOffset] $Device.OnPremisesLastSyncDateTime } else { $null }
$NormalizedDevices.Add([PSCustomObject] @{
Name = $Device.DisplayName
Id = $Device.Id
Comment thread
PrzemyslawKlys marked this conversation as resolved.
EntraDeviceObjectId = $Device.Id
DeviceId = $Device.DeviceId
TrustType = $TrustType
IsSynchronized = [bool] $Device.OnPremisesSyncEnabled
LastSeen = $lastSeen
LastSeenDays = $LastSeenDays
LastSynchronized = $lastSynchronized
LastSynchronizedDays = $LastSynchronizedDays
OwnerDisplayName = $OwnerDisplayName
OwnerEnabled = $OwnerEnabled
OwnerUserPrincipalName = $OwnerUserPrincipalName
})
return
}

$AutopilotDevice = Find-GraphEssentialsAutopilotDevice -Lookup $AutopilotLookup -AzureAdDeviceId $Device.DeviceId
$AutopilotLastContacted = if ($AutopilotDevice) { Get-GraphEssentialsObjectProperty -InputObject $AutopilotDevice -Name @('LastContactedDateTime', 'lastContactedDateTime') } else { $null }
Expand All @@ -130,7 +218,7 @@
LastSeen = $Device.ApproximateLastSignInDateTime
LastSeenDays = $LastSeenDays
Status = $Device.DeviceOwnership
OwnerCount = @($Device.RegisteredOwners).Count
OwnerCount = $OwnerCount
OwnerDisplayName = $OwnerDisplayName
OwnerEnabled = $OwnerEnabled
OwnerUserPrincipalName = $OwnerUserPrincipalName
Expand Down Expand Up @@ -159,14 +247,16 @@
})
}
}
catch {
$Script:Devices = $null
$Script:DevicesDate = $null
catch {
$Script:Devices = $null
$Script:DevicesDate = $null
$Script:DevicesScope = $null
Write-Warning -Message "Get-MyDevice - Failed to get devices. Error: $($_.Exception.Message)"
return
}

$Script:Devices = $DeviceCache
$Script:DevicesDate = Get-Date
$Script:Devices = $DeviceCache
$Script:DevicesDate = Get-Date
$Script:DevicesScope = if ($Synchronized -and $PropertySet -eq 'Computer') { 'Synchronized' } else { 'All' }
$NormalizedDevices
}
Loading