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