EntraDeviceReport.psm1
|
#Region './Private/Assert-GraphConnection.ps1' -1 function Assert-GraphConnection { <# .SYNOPSIS Verifies that an authenticated Microsoft Graph session with the required scopes exists. .DESCRIPTION Inspects the current session via Get-MgContext rather than opening an interactive sign-in, so that the report can be run unattended and so that no implicit authentication side effect occurs. Throws a terminating error naming the missing scopes when the session is absent or under-privileged. Directory.Read.All is accepted as a broader alternative, but Device.Read.All is the least-privilege requirement and remains the default. .PARAMETER RequiredScope One or more Microsoft Graph permission names that must be present in the current session. Defaults to Device.Read.All. .EXAMPLE Assert-GraphConnection -RequiredScope 'Device.Read.All' Returns silently when the session is valid; throws otherwise. .OUTPUTS None. #> [CmdletBinding()] [OutputType([void])] param( [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $RequiredScope = (Get-EntraDeviceReportScope) ) $context = Get-MgContext if ($null -eq $context) { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new("No Microsoft Graph session was found. Run Connect-MgGraph -Scopes 'Device.Read.All' before calling this function."), 'GraphSessionNotFound', [System.Management.Automation.ErrorCategory]::AuthenticationError, 'Get-MgContext')) } # Delegated scopes and application roles are both surfaced through Scopes. $granted = @($context.Scopes) $missing = @($RequiredScope | Where-Object { $granted -notcontains $_ }) $alternative = Get-EntraDeviceReportScope -Kind Alternative $hasAlternative = @($alternative | Where-Object { $granted -contains $_ }).Count -gt 0 if ($missing.Count -gt 0 -and -not $hasAlternative) { $message = "The current Microsoft Graph session (account '{0}', type '{1}') is missing the required permission(s): {2}. Reconnect with Connect-MgGraph -Scopes '{2}', or grant {3} as a broader alternative." -f $context.Account, $context.AuthType, ($missing -join ', '), ($alternative -join ', ') $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.UnauthorizedAccessException]::new($message), 'GraphScopeMissing', [System.Management.Automation.ErrorCategory]::PermissionDenied, ($missing -join ', '))) } Write-Verbose ('Microsoft Graph session validated. Tenant: {0}. AuthType: {1}.' -f $context.TenantId, $context.AuthType) } #EndRegion './Private/Assert-GraphConnection.ps1' 66 #Region './Private/Assert-GraphTenant.ps1' -1 function Assert-GraphTenant { <# .SYNOPSIS Confirms an established session belongs to the intended tenant. .DESCRIPTION Connect-MgGraph accepts -TenantId only on its user, certificate and client secret parameter sets. For managed identity, access token and environment variable authentication the tenant is implied by the host, the token, or the environment, and cannot be requested. This helper closes that gap: the caller states the tenant it intends to reach, and after connecting the resulting context is checked against it. Connecting to the wrong tenant is a silent failure mode otherwise, and a device report from the wrong tenant is worse than no report. A tenant supplied as a domain name cannot be compared against the context, which reports a GUID, without an additional lookup this read-only module does not perform. In that case the check is skipped and the reason is written to the verbose stream rather than passing silently. .PARAMETER ExpectedTenantId The tenant the caller intended to authenticate against, as a GUID or a domain name. .PARAMETER Context The session context returned by Get-MgContext. .EXAMPLE Assert-GraphTenant -ExpectedTenantId '00000000-0000-0000-0000-000000000000' -Context (Get-MgContext) Returns silently when the session is in the expected tenant. .OUTPUTS None. #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ExpectedTenantId, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Context ) $guidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' if ($ExpectedTenantId -notmatch $guidPattern) { Write-Verbose ("Tenant '{0}' was supplied as a domain name; the session reports tenant id '{1}'. Skipping the comparison, which would need a directory lookup this module does not perform." -f $ExpectedTenantId, $Context.TenantId) return } if ($Context.TenantId -ne $ExpectedTenantId) { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new( ("Connected to tenant '{0}' but '{1}' was requested. Disconnect with Disconnect-MgGraph and check the identity, token, or environment variables in use." -f $Context.TenantId, $ExpectedTenantId)), 'GraphTenantMismatch', [System.Management.Automation.ErrorCategory]::InvalidResult, $ExpectedTenantId)) } Write-Verbose ('Session confirmed in the requested tenant {0}.' -f $ExpectedTenantId) } #EndRegion './Private/Assert-GraphTenant.ps1' 72 #Region './Private/ConvertTo-DeviceReportRow.ps1' -1 function ConvertTo-DeviceReportRow { <# .SYNOPSIS Projects one device object into a flat report row. .DESCRIPTION Returns a [PSCustomObject] carrying the eighteen report columns in their defined order. The function performs projection only; the management and staleness determinations are delegated to Resolve-DeviceManagementState and Test-DeviceStale. Named ConvertTo-* rather than New-* because it transforms an input object into another representation and creates no state. A New-* function without SupportsShouldProcess fails the PSScriptAnalyzer rule PSUseShouldProcessForStateChangingFunctions, and declaring ShouldProcess on a pure projection would be misleading. .PARAMETER Device A device object as returned by Get-MgDevice. .PARAMETER CutoffUtc The UTC instant before which a sign-in counts as stale. .PARAMETER ReferenceUtc The UTC instant the run is measured from. .PARAMETER StaleThresholdDays The threshold in days, echoed into every row for evidential clarity. .EXAMPLE $devices | ConvertTo-DeviceReportRow -CutoffUtc $cutoff -ReferenceUtc $now -StaleThresholdDays 180 Emits one report row per device. .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [object] $Device, [Parameter(Mandatory)] [datetime] $CutoffUtc, [Parameter(Mandatory)] [datetime] $ReferenceUtc, [Parameter(Mandatory)] [ValidateRange(1, 3650)] [int] $StaleThresholdDays ) process { $state = Resolve-DeviceManagementState -Device $Device $staleness = Test-DeviceStale -Device $Device -CutoffUtc $CutoffUtc -ReferenceUtc $ReferenceUtc $trustType = [string](Get-DevicePropertyValue -Device $Device -GraphName 'trustType') $syncEnabled = Get-DevicePropertyValue -Device $Device -GraphName 'onPremisesSyncEnabled' [pscustomobject][ordered]@{ DeviceName = [string](Get-DevicePropertyValue -Device $Device -GraphName 'displayName') ObjectId = [string](Get-DevicePropertyValue -Device $Device -GraphName 'id') DeviceId = [string](Get-DevicePropertyValue -Device $Device -GraphName 'deviceId') Managed = $state.Managed UnManaged = $state.UnManaged ManagementType = $state.ManagementType Ownership = [string](Get-DevicePropertyValue -Device $Device -GraphName 'deviceOwnership') TrustType = $trustType Platform = [string](Get-DevicePropertyValue -Device $Device -GraphName 'operatingSystem') OperatingSystemVersion = [string](Get-DevicePropertyValue -Device $Device -GraphName 'operatingSystemVersion') LastSignIn = Format-Iso8601Utc -Value $staleness.LastSignInUtc LastSignInKnown = $staleness.LastSignInKnown DaysSinceLastSignIn = $staleness.DaysSinceLastSignIn IsStale = $staleness.IsStale StaleThresholdDays = $StaleThresholdDays AccountEnabled = (Get-DevicePropertyValue -Device $Device -GraphName 'accountEnabled') -eq $true RegistrationDateTime = Format-Iso8601Utc -Value (Get-DevicePropertyValue -Device $Device -GraphName 'registrationDateTime') IsHybridJoined = ($trustType -eq 'ServerAd') -or ($syncEnabled -eq $true) } } } #EndRegion './Private/ConvertTo-DeviceReportRow.ps1' 84 #Region './Private/ConvertTo-UtcDateTime.ps1' -1 function ConvertTo-UtcDateTime { <# .SYNOPSIS Converts a Microsoft Graph timestamp value into a UTC [datetime]. .DESCRIPTION Microsoft Graph timestamps arrive either as a typed [datetime] or [datetimeoffset] from the PowerShell SDK model, or as an ISO 8601 string when the value is carried in the open-type AdditionalProperties dictionary. This helper normalises all three forms to a UTC [datetime], returning $null when the value is absent or cannot be parsed. Parsing always uses the invariant culture so that host locale never affects the result. .PARAMETER Value The raw timestamp value to convert. May be $null. .EXAMPLE ConvertTo-UtcDateTime -Value '2025-03-01T09:15:00Z' Returns the equivalent UTC [datetime]. .OUTPUTS System.DateTime, or $null when the value is absent or unparseable. #> [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory)] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } if ($Value -is [datetimeoffset]) { return ([datetimeoffset]$Value).UtcDateTime } if ($Value -is [datetime]) { return ([datetime]$Value).ToUniversalTime() } $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text)) { return $null } $parsed = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal if ([datetime]::TryParse($text, [cultureinfo]::InvariantCulture, $styles, [ref] $parsed)) { return $parsed } Write-Verbose ("Unable to parse '{0}' as a timestamp; treating it as unknown." -f $text) return $null } #EndRegion './Private/ConvertTo-UtcDateTime.ps1' 52 #Region './Private/Export-DeviceReportCsv.ps1' -1 function Export-DeviceReportCsv { <# .SYNOPSIS Writes report rows to a CSV file. .DESCRIPTION The only filesystem side effect in this function set, and therefore the only place ShouldProcess is enforced. Writes a comma-delimited, UTF-8 (no byte order mark) file with no type information header. The target directory must already exist; this function creates no directories. .PARAMETER Row The report rows to write. May be empty. .PARAMETER Path Destination CSV path. Relative paths resolve against the current provider location. .EXAMPLE Export-DeviceReportCsv -Row $rows -Path './unmanaged-devices.csv' Writes the rows to the named file. .OUTPUTS None. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([void])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [psobject[]] $Row, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) $resolved = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) $parent = Split-Path -Path $resolved -Parent if (-not (Test-Path -LiteralPath $parent -PathType Container)) { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.IO.DirectoryNotFoundException]::new(("The output directory '{0}' does not exist. Create it, or supply a -Path inside an existing directory." -f $parent)), 'ReportDirectoryNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $parent)) } if ($Row.Count -eq 0) { Write-Warning ("No rows matched the requested filters; '{0}' was not written." -f $resolved) return } if (-not $PSCmdlet.ShouldProcess($resolved, 'Write device report CSV')) { return } try { $Row | Export-Csv -LiteralPath $resolved -NoTypeInformation -Delimiter ',' -Encoding utf8NoBOM -ErrorAction Stop Write-Verbose ('Wrote {0} row(s) to {1}.' -f $Row.Count, $resolved) } catch { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.IO.IOException]::new(("Failed to write the CSV report to '{0}': {1}" -f $resolved, $_.Exception.Message), $_.Exception), 'ReportCsvWriteFailed', [System.Management.Automation.ErrorCategory]::WriteError, $resolved)) } } #EndRegion './Private/Export-DeviceReportCsv.ps1' 71 #Region './Private/Format-Iso8601Utc.ps1' -1 function Format-Iso8601Utc { <# .SYNOPSIS Renders a Microsoft Graph timestamp as an ISO 8601 UTC string. .DESCRIPTION Produces the exact form yyyy-MM-ddTHH:mm:ssZ using the invariant culture, so that the emitted CSV is locale-independent. Returns an empty string when the value is absent or unparseable, which keeps the CSV cell empty rather than writing a misleading placeholder. .PARAMETER Value The raw timestamp value to render. May be $null. .EXAMPLE Format-Iso8601Utc -Value ([datetime]'2025-03-01 09:15:00Z') Returns '2025-03-01T09:15:00Z'. .OUTPUTS System.String #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowNull()] [object] $Value ) $utc = ConvertTo-UtcDateTime -Value $Value if ($null -eq $utc) { return '' } return $utc.ToString('yyyy-MM-ddTHH:mm:ss\Z', [cultureinfo]::InvariantCulture) } #EndRegion './Private/Format-Iso8601Utc.ps1' 36 #Region './Private/Get-DevicePropertyValue.ps1' -1 function Get-DevicePropertyValue { <# .SYNOPSIS Reads a single Microsoft Graph device property from an SDK object. .DESCRIPTION The Graph device resource is an open type. The Microsoft Graph PowerShell SDK surfaces modelled properties in PascalCase directly on the object, and any property outside the model in the AdditionalProperties dictionary keyed by its Graph camelCase name. Reading only one of those two locations silently yields $null for whichever properties happen to live in the other, so this helper checks the typed property first and then falls back to AdditionalProperties. Keeping the access pattern in one place is what prevents a whole estate from being misclassified. .PARAMETER Device A device object as returned by Get-MgDevice. .PARAMETER GraphName The Microsoft Graph property name in camelCase, for example 'managementType'. .EXAMPLE Get-DevicePropertyValue -Device $device -GraphName 'isManaged' Returns the isManaged value, or $null when it was not selected. .OUTPUTS System.Object #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Device, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GraphName ) $pascalName = [char]::ToUpperInvariant($GraphName[0]) + $GraphName.Substring(1) $typed = $Device.PSObject.Properties[$pascalName] if ($null -ne $typed -and $null -ne $typed.Value) { return $typed.Value } $additional = $Device.PSObject.Properties['AdditionalProperties'] if ($null -ne $additional -and $additional.Value -is [System.Collections.IDictionary]) { $bag = $additional.Value # ContainsKey, not Contains. The SDK returns Dictionary[string, object], # whose only single-argument Contains is the explicit non-generic # IDictionary implementation, which PowerShell will not dispatch to -- # it throws "Cannot find an overload for Contains and the argument # count: 1" once per device. A PowerShell hashtable does have # Contains(object), which is why this only ever failed against a real # tenant. ContainsKey exists on both. if ($bag.ContainsKey($GraphName)) { return $bag[$GraphName] } } return $null } #EndRegion './Private/Get-DevicePropertyValue.ps1' 65 #Region './Private/Get-EntraDeviceObject.ps1' -1 function Get-EntraDeviceObject { <# .SYNOPSIS Retrieves every registered Entra ID device object with an explicit property selection. .DESCRIPTION Calls Get-MgDevice with -All so that the SDK follows @odata.nextLink and returns the complete collection, and with an explicit -Property list so that only the fifteen properties this report needs cross the wire. Every named property is documented on the Microsoft Graph v1.0 device resource type. No server-side $filter is applied. Filtering devices by approximateLastSignInDateTime, and combining that with a management filter, requires the advanced query capabilities (ConsistencyLevel eventual plus $count) whose behaviour on the device resource this author has not confirmed. Client-side evaluation is therefore used throughout. The trade-off is that the full estate is retrieved even for a narrow report; at the order of ten thousand objects that is acceptable, and it avoids silently under-reporting. .PARAMETER PageSize Number of objects requested per Graph page. Defaults to 999, the usual maximum for directory object collections. .EXAMPLE Get-EntraDeviceObject -Verbose Returns all device objects with the reporting property set populated. .OUTPUTS System.Object[] #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter()] [ValidateRange(1, 999)] [int] $PageSize = 999 ) $selectProperty = @( 'id', 'displayName', 'deviceId', 'isManaged', 'isCompliant', 'managementType', 'deviceOwnership', 'trustType', 'profileType', 'operatingSystem', 'operatingSystemVersion', 'approximateLastSignInDateTime', 'accountEnabled', 'registrationDateTime', 'onPremisesSyncEnabled' ) Write-Verbose ('Retrieving /devices with an explicit selection of {0} properties, page size {1}.' -f $selectProperty.Count, $PageSize) try { Get-MgDevice -All -PageSize $PageSize -Property $selectProperty -ErrorAction Stop } catch { # Distinguish the failure modes an operator must act on differently: # a missing scope, an expired session, throttling, or a transport fault. $status = 0 $response = $_.Exception.PSObject.Properties['Response'] if ($null -ne $response -and $null -ne $response.Value) { $status = [int]$response.Value.StatusCode } $detail = switch ($status) { 401 { @{ Category = [System.Management.Automation.ErrorCategory]::AuthenticationError; Hint = "The Microsoft Graph session is no longer valid. Reconnect with Connect-MgGraph -Scopes 'Device.Read.All'." } } 403 { @{ Category = [System.Management.Automation.ErrorCategory]::PermissionDenied; Hint = 'The signed-in identity lacks Device.Read.All (or Directory.Read.All) for the /devices resource.' } } 429 { @{ Category = [System.Management.Automation.ErrorCategory]::LimitsExceeded; Hint = 'Microsoft Graph throttled the request. Retry after the interval given in the Retry-After response header; this function implements no automatic back-off.' } } default { @{ Category = [System.Management.Automation.ErrorCategory]::ConnectionError; Hint = 'Check connectivity to graph.microsoft.com and current Microsoft Graph service health.' } } } $message = 'Failed to retrieve device objects from Microsoft Graph (/devices, page size {0}, HTTP {1}). {2} Underlying error: {3}' -f $PageSize, $(if ($status -gt 0) { $status } else { 'unknown' }), $detail.Hint, $_.Exception.Message $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($message, $_.Exception), 'EntraDeviceRetrievalFailed', $detail.Category, '/devices')) } } #EndRegion './Private/Get-EntraDeviceObject.ps1' 80 #Region './Private/Get-EntraDeviceReportScope.ps1' -1 function Get-EntraDeviceReportScope { <# .SYNOPSIS Returns the Microsoft Graph permissions this module needs. .DESCRIPTION The single source of truth for the module's Graph scopes. Both Connect-EntraDeviceReport (when requesting delegated consent) and Assert-GraphConnection (when validating an existing session) read from here, so the least-privilege requirement cannot drift between what is requested and what is checked. Device.Read.All is the least-privilege requirement and covers every property the report projects from /devices. Directory.Read.All is a broader alternative that also satisfies the read, and is accepted when validating a session, but is never requested by default. The same names serve delegated scopes and application roles: Graph surfaces both through the session context's Scopes collection. .PARAMETER Kind Required - the least-privilege scope set (default). Alternative - broader scopes accepted in place of the required set. All - both, for documentation and validation. .EXAMPLE Get-EntraDeviceReportScope Returns @('Device.Read.All'). .EXAMPLE Get-EntraDeviceReportScope -Kind All Returns every scope that satisfies the module, least privilege first. .OUTPUTS System.String[] #> [CmdletBinding()] [OutputType([string[]])] param( [Parameter()] [ValidateSet('Required', 'Alternative', 'All')] [string] $Kind = 'Required' ) [string[]] $required = @('Device.Read.All') [string[]] $alternative = @('Directory.Read.All') # Collected into one typed variable so the declared [string[]] output holds # for every branch, including the single-element ones. [string[]] $scope = switch ($Kind) { 'Alternative' { $alternative } 'All' { $required + $alternative } default { $required } } return $scope } #EndRegion './Private/Get-EntraDeviceReportScope.ps1' 60 #Region './Private/Get-GraphConnectionParameter.ps1' -1 function Get-GraphConnectionParameter { <# .SYNOPSIS Builds the Connect-MgGraph argument set for one authentication method. .DESCRIPTION Translates the parameter set chosen on Connect-EntraDeviceReport into the exact hashtable Connect-MgGraph expects, so that the dispatch logic is a pure, testable transformation with no side effect and no network call. Each branch maps to one Connect-MgGraph parameter set: Connect-MgGraph accepts -TenantId only on its user, certificate and client secret parameter sets, so TenantId is deliberately NOT passed for managed identity, access token, or environment variable authentication. Connect-EntraDeviceReport still requires it for every method and verifies it against the resulting context instead, via Assert-GraphTenant. ManagedIdentity -> -Identity [-ClientId] Certificate -> -ClientId -TenantId -CertificateThumbprint CertificateSubjectName -> -ClientId -TenantId -CertificateSubjectName CertificateObject -> -ClientId -TenantId -Certificate ClientSecret -> -ClientSecretCredential -TenantId AccessToken -> -AccessToken EnvironmentVariable -> -EnvironmentVariable Interactive -> -Scopes [-TenantId] [-LoginHint] [-UseDeviceCode] NoWelcome is always set: the caller receives the resulting context as an object, so the SDK banner is noise. .PARAMETER ParameterSetName The parameter set selected on the public function. .PARAMETER BoundParameter The public function's $PSBoundParameters. .EXAMPLE Get-GraphConnectionParameter -ParameterSetName 'ManagedIdentity' -BoundParameter @{} Returns @{ Identity = $true; NoWelcome = $true }. .OUTPUTS System.Collections.Hashtable #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [ValidateSet('Interactive', 'ManagedIdentity', 'Certificate', 'CertificateSubjectName', 'CertificateObject', 'ClientSecret', 'AccessToken', 'EnvironmentVariable')] [string] $ParameterSetName, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $BoundParameter ) $splat = @{ NoWelcome = $true } switch ($ParameterSetName) { 'ManagedIdentity' { $splat['Identity'] = $true # Present only for a user-assigned identity; omitted means system-assigned. if ($BoundParameter.ContainsKey('ClientId')) { $splat['ClientId'] = $BoundParameter['ClientId'] } } 'Certificate' { $splat['ClientId'] = $BoundParameter['ClientId'] $splat['TenantId'] = $BoundParameter['TenantId'] $splat['CertificateThumbprint'] = $BoundParameter['CertificateThumbprint'] } 'CertificateSubjectName' { $splat['ClientId'] = $BoundParameter['ClientId'] $splat['TenantId'] = $BoundParameter['TenantId'] $splat['CertificateSubjectName'] = $BoundParameter['CertificateSubjectName'] } 'CertificateObject' { $splat['ClientId'] = $BoundParameter['ClientId'] $splat['TenantId'] = $BoundParameter['TenantId'] $splat['Certificate'] = $BoundParameter['Certificate'] } 'ClientSecret' { # Connect-MgGraph takes a PSCredential whose user name is the app id. $splat['ClientSecretCredential'] = [pscredential]::new( $BoundParameter['ClientId'], $BoundParameter['ClientSecret']) $splat['TenantId'] = $BoundParameter['TenantId'] } 'AccessToken' { $splat['AccessToken'] = $BoundParameter['AccessToken'] } 'EnvironmentVariable' { $splat['EnvironmentVariable'] = $true } default { # Fail loudly rather than let Connect-MgGraph fall back to its own # default scope set, which would not be least privilege. if (-not $BoundParameter['Scopes']) { throw 'Interactive authentication requires at least one delegated scope.' } $splat['Scopes'] = $BoundParameter['Scopes'] if ($BoundParameter.ContainsKey('TenantId')) { $splat['TenantId'] = $BoundParameter['TenantId'] } if ($BoundParameter.ContainsKey('LoginHint')) { $splat['LoginHint'] = $BoundParameter['LoginHint'] } if ($BoundParameter['UseDeviceCode']) { $splat['UseDeviceCode'] = $true } } } # Sovereign clouds apply to every method. if ($BoundParameter.ContainsKey('Environment')) { $splat['Environment'] = $BoundParameter['Environment'] } return $splat } #EndRegion './Private/Get-GraphConnectionParameter.ps1' 114 #Region './Private/Resolve-DeviceManagementState.ps1' -1 function Resolve-DeviceManagementState { <# .SYNOPSIS Determines whether a single device is managed or unmanaged. .DESCRIPTION Applies one evaluation and derives both booleans from it, so that Managed and UnManaged are guaranteed mutually exclusive rather than being two independent tests that could disagree. A device is Managed when isManaged is $true AND managementType names a real management channel. A device is UnManaged when isManaged is $false or null, OR managementType is null or empty. Note: 'unknown' is one of the documented managementType values. It is treated here as the absence of a management channel rather than as a channel, which is a deliberate reading of the classification rule and is flagged for operator confirmation. .PARAMETER Device A device object as returned by Get-MgDevice. .EXAMPLE Resolve-DeviceManagementState -Device $device Returns an object carrying Managed, UnManaged and ManagementType. .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [object] $Device ) process { $isManaged = Get-DevicePropertyValue -Device $Device -GraphName 'isManaged' $managementType = [string](Get-DevicePropertyValue -Device $Device -GraphName 'managementType') $hasChannel = (-not [string]::IsNullOrWhiteSpace($managementType)) -and ($managementType -ne 'unknown') $managed = ($isManaged -eq $true) -and $hasChannel [pscustomobject]@{ Managed = $managed UnManaged = (-not $managed) ManagementType = if ([string]::IsNullOrWhiteSpace($managementType)) { '' } else { $managementType } } } } #EndRegion './Private/Resolve-DeviceManagementState.ps1' 53 #Region './Private/Test-DeviceStale.ps1' -1 function Test-DeviceStale { <# .SYNOPSIS Evaluates a single device against the staleness cut-off. .DESCRIPTION A device is stale when its approximateLastSignInDateTime is older than the supplied UTC cut-off. A device whose approximateLastSignInDateTime is null is treated as stale and is distinguished in the output by LastSignInKnown being $false and DaysSinceLastSignIn being $null, so that "never seen" is never confused with "seen a long time ago". The cut-off and the reference instant are passed in rather than recomputed per device, so that every row in one run is measured against exactly the same moment. .PARAMETER Device A device object as returned by Get-MgDevice. .PARAMETER CutoffUtc The UTC instant before which a sign-in counts as stale. .PARAMETER ReferenceUtc The UTC instant the run is measured from, used for the day count. .EXAMPLE Test-DeviceStale -Device $device -CutoffUtc $cutoff -ReferenceUtc $now Returns LastSignInUtc, LastSignInKnown, DaysSinceLastSignIn, IsStale. .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Device, [Parameter(Mandatory)] [datetime] $CutoffUtc, [Parameter(Mandatory)] [datetime] $ReferenceUtc ) $raw = Get-DevicePropertyValue -Device $Device -GraphName 'approximateLastSignInDateTime' $lastSignIn = ConvertTo-UtcDateTime -Value $raw if ($null -eq $lastSignIn) { return [pscustomobject]@{ LastSignInUtc = $null LastSignInKnown = $false DaysSinceLastSignIn = $null IsStale = $true } } [pscustomobject]@{ LastSignInUtc = $lastSignIn LastSignInKnown = $true DaysSinceLastSignIn = [int][math]::Floor(($ReferenceUtc - $lastSignIn).TotalDays) IsStale = ($lastSignIn -lt $CutoffUtc) } } #EndRegion './Private/Test-DeviceStale.ps1' 68 #Region './Public/Connect-EntraDeviceReport.ps1' -1 function Connect-EntraDeviceReport { <# .SYNOPSIS Establishes the Microsoft Graph session used by Get-EntraUnmanagedDeviceReport. .DESCRIPTION Wraps Connect-MgGraph and exposes one parameter set per supported authentication method, so the choice is explicit at the call site rather than implied. Returns the resulting authentication context. Methods, strongest first: -ManagedIdentity Anything running on Azure (Automation, Functions, VMs, Container Apps). No secret exists to leak. Add -ClientId for a user-assigned identity. -AccessToken A token you acquired yourself. This is the workload identity federation path: exchange the federated credential for a Graph token in your CI system, then pass it here. Still short-lived, still no stored secret. -CertificateThumbprint Unattended automation where neither of the -CertificateSubjectName above is available. Certificate app-only. -Certificate -EnvironmentVariable Reads the SDK's own AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET variables. Convenient in containers. (default, interactive) A human at a workstation with a browser. -ClientSecret Last resort. Expires, leaks, and needs a rotation process you must own. -UseDeviceCode Only when the authenticating device has no browser. See the warning below. This function is the only place the module authenticates. Get-EntraUnmanagedDeviceReport never signs in: it inspects the existing session and fails closed. That separation is deliberate, so an unattended report run cannot trigger an interactive prompt. .PARAMETER Scopes Delegated permissions to request. Interactive and device-code only; application methods use pre-consented app roles. Defaults to the module's least-privilege requirement from Get-EntraDeviceReportScope, currently Device.Read.All, which is the same set Assert-GraphConnection validates an existing session against. .PARAMETER TenantId Directory tenant to authenticate against. Mandatory for every method. Connect-MgGraph accepts it directly for the interactive, certificate and client secret flows; for managed identity, access token and environment variable authentication the tenant cannot be requested, so it is verified against the resulting session instead. Supply a GUID for that verification to run: a domain name cannot be compared against the session tenant id without a directory lookup this read-only module does not perform. .PARAMETER LoginHint Pre-populates the account on the interactive sign-in page. .PARAMETER UseDeviceCode Authenticate with the device code flow. See the NOTES section: this is a high-risk flow that many tenants block outright. .PARAMETER ManagedIdentity Use the Azure managed identity of the host. .PARAMETER ClientId Application (client) id. Required for the certificate and client secret methods; optional with -ManagedIdentity, where it selects a user-assigned identity. .PARAMETER CertificateThumbprint Thumbprint of a certificate in the local certificate store. .PARAMETER CertificateSubjectName Subject name of a certificate in the local certificate store. .PARAMETER Certificate An already-loaded X509 certificate object. .PARAMETER ClientSecret Application client secret. Accepted as a SecureString only. .PARAMETER AccessToken A Graph access token acquired elsewhere. SecureString only. .PARAMETER EnvironmentVariable Authenticate from the SDK's standard Azure environment variables. .PARAMETER Environment Named Graph environment, for sovereign clouds. Omit for commercial. .EXAMPLE Connect-EntraDeviceReport -ManagedIdentity -TenantId '00000000-0000-0000-0000-000000000000' Authenticates as the host's system-assigned managed identity. The preferred method for anything running in Azure. .EXAMPLE Connect-EntraDeviceReport -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -CertificateThumbprint 'A1B2C3D4E5F60718293A4B5C6D7E8F9A0B1C2D3E' Certificate app-only authentication for unattended automation. .EXAMPLE $token = Get-FederatedGraphToken # your CI system's own exchange Connect-EntraDeviceReport -AccessToken $token -TenantId '00000000-0000-0000-0000-000000000000' Workload identity federation: a short-lived token acquired outside this module, with no secret stored anywhere. .EXAMPLE Connect-EntraDeviceReport -TenantId 'contoso.onmicrosoft.com' Interactive browser sign-in requesting Device.Read.All. .EXAMPLE Connect-EntraDeviceReport -TenantId 'contoso.onmicrosoft.com' -UseDeviceCode Device code flow, for a genuinely browserless device such as a remote SSH session. Emits a warning; may be blocked by tenant policy. .OUTPUTS Microsoft.Graph.PowerShell.Authentication.AuthContext .NOTES Device code flow is not a default and is not simply "the CLI option". Microsoft classifies it as high risk and recommends blocking it where possible. A Microsoft-managed Conditional Access policy blocks it tenant-wide, and any Conditional Access authentication-flows policy targeting device code will block it too. When that happens the script fails at sign-in and the failure looks like a broken script rather than a policy decision. Microsoft's security-operations guidance also treats device code flow appearing outside an input-constrained device as a signal to investigate. Prefer managed identity, workload identity federation, or a certificate for anything unattended. This function performs no tenant reads or writes beyond authentication itself. #> [CmdletBinding(DefaultParameterSetName = 'Interactive', SupportsShouldProcess)] [OutputType('Microsoft.Graph.PowerShell.Authentication.AuthContext')] param( [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string[]] $Scopes = (Get-EntraDeviceReportScope), [Parameter(ParameterSetName = 'Interactive', Mandatory)] [Parameter(ParameterSetName = 'ManagedIdentity', Mandatory)] [Parameter(ParameterSetName = 'Certificate', Mandatory)] [Parameter(ParameterSetName = 'CertificateSubjectName', Mandatory)] [Parameter(ParameterSetName = 'CertificateObject', Mandatory)] [Parameter(ParameterSetName = 'ClientSecret', Mandatory)] [Parameter(ParameterSetName = 'AccessToken', Mandatory)] [Parameter(ParameterSetName = 'EnvironmentVariable', Mandatory)] [ValidateNotNullOrEmpty()] [string] $TenantId, [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string] $LoginHint, [Parameter(ParameterSetName = 'Interactive')] [switch] $UseDeviceCode, [Parameter(ParameterSetName = 'ManagedIdentity', Mandatory)] [switch] $ManagedIdentity, [Parameter(ParameterSetName = 'ManagedIdentity')] [Parameter(ParameterSetName = 'Certificate', Mandatory)] [Parameter(ParameterSetName = 'CertificateSubjectName', Mandatory)] [Parameter(ParameterSetName = 'CertificateObject', Mandatory)] [Parameter(ParameterSetName = 'ClientSecret', Mandatory)] [ValidateNotNullOrEmpty()] [string] $ClientId, [Parameter(ParameterSetName = 'Certificate', Mandatory)] [ValidateNotNullOrEmpty()] [string] $CertificateThumbprint, [Parameter(ParameterSetName = 'CertificateSubjectName', Mandatory)] [ValidateNotNullOrEmpty()] [string] $CertificateSubjectName, [Parameter(ParameterSetName = 'CertificateObject', Mandatory)] [ValidateNotNull()] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(ParameterSetName = 'ClientSecret', Mandatory)] [ValidateNotNull()] [securestring] $ClientSecret, [Parameter(ParameterSetName = 'AccessToken', Mandatory)] [ValidateNotNull()] [securestring] $AccessToken, [Parameter(ParameterSetName = 'EnvironmentVariable', Mandatory)] [switch] $EnvironmentVariable, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Environment ) if ($UseDeviceCode) { Write-Warning ('Device code flow is a high-risk authentication method that Microsoft recommends blocking. ' + 'It will fail at sign-in if the tenant applies the Microsoft-managed device code block or any ' + 'Conditional Access authentication-flows policy. Use managed identity, workload identity federation ' + '(-AccessToken), or a certificate for anything unattended.') } # A parameter left at its default never appears in PSBoundParameters, so the # -Scopes default would silently reach Connect-MgGraph as $null. Copy rather # than mutate the automatic variable. $boundParameter = @{} + $PSBoundParameters if ($PSCmdlet.ParameterSetName -eq 'Interactive') { $boundParameter['Scopes'] = $Scopes } $connectParameter = Get-GraphConnectionParameter -ParameterSetName $PSCmdlet.ParameterSetName -BoundParameter $boundParameter # The parameter set is 'Interactive' even when device code is in use, which # makes an unqualified failure message read misleadingly. $method = if ($UseDeviceCode) { 'Interactive (device code)' } else { $PSCmdlet.ParameterSetName } $target = if ($TenantId) { $TenantId } else { 'the default tenant' } if (-not $PSCmdlet.ShouldProcess($target, ('Connect to Microsoft Graph using {0} authentication' -f $method))) { return } try { # Connect-MgGraph writes the device code prompt to its SUCCESS stream. # Piping to Out-Null discarded it, leaving the operator staring at # nothing until the flow timed out. Relay whatever it emits to the # information stream instead, forced visible, so the prompt reaches the # console without the connection banner leaking into this function's # own output. Connect-MgGraph @connectParameter -ErrorAction Stop | ForEach-Object { Write-Information -MessageData $_ -InformationAction Continue } } catch { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new( ('Failed to connect to Microsoft Graph using {0} authentication: {1}' -f $method, $_.Exception.Message), $_.Exception), 'GraphConnectionFailed', [System.Management.Automation.ErrorCategory]::AuthenticationError, $PSCmdlet.ParameterSetName)) } $context = Get-MgContext if ($null -eq $context) { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new( ('Connect-MgGraph reported success using {0} authentication but no session context is present.' -f $method)), 'GraphContextMissingAfterConnect', [System.Management.Automation.ErrorCategory]::AuthenticationError, $PSCmdlet.ParameterSetName)) } # Connect-MgGraph cannot be told the tenant for managed identity, access # token, or environment variable auth, so confirm it after the fact rather # than assume it. A report from the wrong tenant is worse than no report. Assert-GraphTenant -ExpectedTenantId $TenantId -Context $context # Never echo the token, secret, or thumbprint that got us here. Write-Verbose ('Connected to Microsoft Graph. Method: {0}. Tenant: {1}. AuthType: {2}.' -f $method, $context.TenantId, $context.AuthType) return $context } #EndRegion './Public/Connect-EntraDeviceReport.ps1' 270 #Region './Public/Get-EntraUnmanagedDeviceReport.ps1' -1 function Get-EntraUnmanagedDeviceReport { <# .SYNOPSIS Produces a read-only report of unmanaged and stale Microsoft Entra ID device objects. .DESCRIPTION Queries the Microsoft Graph /devices resource, classifies every registered device object as managed or unmanaged, evaluates each against a configurable staleness threshold, and returns or exports a flat set of report rows. Classification. A device is Managed when isManaged is $true and managementType names a management channel; it is UnManaged when isManaged is $false or null, or managementType is null or empty. The two columns are derived from a single evaluation and are therefore always mutually exclusive. Device ownership (company, personal, unknown) is reported as its own column so that the output can be reconciled against a Corporate / BYOD / Unknown split, and is not folded into the managed determination. Staleness. A device is stale when its approximateLastSignInDateTime is older than the UTC instant $StaleAfterDays before the start of the run. A device that has never signed in is treated as stale and is distinguishable by LastSignInKnown being $false. This function is strictly read-only. It reads the current Graph session, reads device objects, and optionally writes one CSV file. It makes no change to any tenant object. Requires an existing Microsoft Graph session carrying Device.Read.All (Directory.Read.All is accepted as a broader alternative). No interactive sign-in is initiated. .PARAMETER StaleAfterDays Number of days without a sign-in after which a device is considered stale. Defaults to 180. Changing this value is the only change required to alter which rows carry IsStale = $true. .PARAMETER Path Destination CSV path. When omitted, report objects are returned to the pipeline and no file is written. .PARAMETER IncludeManaged Include managed devices in the output, for full-estate reconciliation. By default only unmanaged devices are returned. .PARAMETER StaleOnly Restrict the output to stale devices. .PARAMETER PassThru Emit the report objects as well as writing the CSV. Only meaningful alongside -Path. .EXAMPLE Connect-MgGraph -Scopes 'Device.Read.All' -TenantId 'contoso.onmicrosoft.com' Get-EntraUnmanagedDeviceReport -Verbose Returns every unmanaged device object, using the default 180-day staleness threshold, to the pipeline. .EXAMPLE Get-EntraUnmanagedDeviceReport -Path './DT-001-unmanaged.csv' Writes the default unmanaged-device report to a CSV file. .EXAMPLE Get-EntraUnmanagedDeviceReport -StaleAfterDays 90 -StaleOnly -Path './DT-001-stale-90.csv' -PassThru Writes only devices unseen for 90 days and also returns the rows. .EXAMPLE Get-EntraUnmanagedDeviceReport -IncludeManaged -Path './DT-001-full-estate.csv' Writes the complete estate, managed and unmanaged, for reconciliation against the assessment device counts. .OUTPUTS System.Management.Automation.PSCustomObject .NOTES Read-only. No tenant object is created, modified or removed. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter()] [ValidateRange(1, 3650)] [int] $StaleAfterDays = 180, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter()] [switch] $IncludeManaged, [Parameter()] [switch] $StaleOnly, [Parameter()] [switch] $PassThru ) Assert-GraphConnection -RequiredScope 'Device.Read.All' # One reference instant for the whole run, so every row is measured alike. $referenceUtc = (Get-Date).ToUniversalTime() $cutoffUtc = $referenceUtc.AddDays(-$StaleAfterDays) Write-Verbose ('Staleness cut-off: {0} ({1} days).' -f $cutoffUtc.ToString('yyyy-MM-ddTHH:mm:ss\Z', [cultureinfo]::InvariantCulture), $StaleAfterDays) $devices = @(Get-EntraDeviceObject) Write-Verbose ('Retrieved {0} device object(s).' -f $devices.Count) Write-Progress -Activity 'Entra device report' -Status ('Projecting {0} device object(s)' -f $devices.Count) $rows = @($devices | ConvertTo-DeviceReportRow -CutoffUtc $cutoffUtc -ReferenceUtc $referenceUtc -StaleThresholdDays $StaleAfterDays) Write-Progress -Activity 'Entra device report' -Completed # Filtering is client-side; see the note in Get-EntraDeviceObject. $scoped = if ($IncludeManaged) { $rows } else { @($rows | Where-Object -Property UnManaged -EQ -Value $true) } $scoped = if ($StaleOnly) { @($scoped | Where-Object -Property IsStale -EQ -Value $true) } else { $scoped } Write-Verbose ('{0} of {1} row(s) matched the requested scope.' -f @($scoped).Count, $rows.Count) if (-not $PSBoundParameters.ContainsKey('Path')) { return $scoped } # The decision to write is taken here, at the operator-facing surface, so that # -WhatIf and -Confirm report once against the path the operator supplied. # Export-DeviceReportCsv keeps its own guard for defence in depth, but is not # re-prompted once the decision has been made. if ($PSCmdlet.ShouldProcess($Path, 'Write device report CSV')) { Export-DeviceReportCsv -Row @($scoped) -Path $Path -Confirm:$false } if ($PassThru) { return $scoped } } #EndRegion './Public/Get-EntraUnmanagedDeviceReport.ps1' 138 |