Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
81 changes: 81 additions & 0 deletions Private/Get-GraphEssentialsPagedInventory.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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
if ($errorRecord.Exception.Response -and $errorRecord.Exception.Response.StatusCode) {
$statusCode = [int] $errorRecord.Exception.Response.StatusCode
}
$message = [string] $errorRecord.Exception.Message
$transient = $statusCode -in @(408, 429, 500, 502, 503, 504) -or
$message -match '(?i)timed?\s*out|timeout|cancell?ed.*300 seconds|connection.*(closed|reset)|transport stream'
Comment thread
PrzemyslawKlys marked this conversation as resolved.
Outdated

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 ($statusCode -eq 429 -and $errorRecord.Exception.Response.Headers) {
$headers = $errorRecord.Exception.Response.Headers
$retryAfter = $null
try { $retryAfter = @($headers['Retry-After'])[0] } catch { }
Comment thread
PrzemyslawKlys marked this conversation as resolved.
Outdated
$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 was throttled for $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'
}
}
119 changes: 86 additions & 33 deletions Public/Get-MyDevice.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@
.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.

.EXAMPLE
Get-MyDevice
Expand All @@ -35,23 +41,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 +76,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'
Comment thread
PrzemyslawKlys marked this conversation as resolved.
Outdated
{ 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 +113,56 @@
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)
$OwnerEnabled = [System.Collections.Generic.List[string]]::new()
$OwnerUserPrincipalName = [System.Collections.Generic.List[string]]::new()
foreach ($Owner in $Device.RegisteredOwners) {
$ownerProperties = if ($Owner.AdditionalProperties) { $Owner.AdditionalProperties } else { $Owner }
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.
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 +181,7 @@
LastSeen = $Device.ApproximateLastSignInDateTime
LastSeenDays = $LastSeenDays
Status = $Device.DeviceOwnership
OwnerCount = @($Device.RegisteredOwners).Count
OwnerCount = @($Device.RegisteredOwners | Where-Object { $null -ne $_ }).Count
Comment thread
PrzemyslawKlys marked this conversation as resolved.
Outdated
OwnerDisplayName = $OwnerDisplayName
OwnerEnabled = $OwnerEnabled
OwnerUserPrincipalName = $OwnerUserPrincipalName
Expand Down Expand Up @@ -159,14 +210,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