EntraDeviceReport.psd1

#
# Module manifest for module 'EntraDeviceReport'
#
# Generated by: Olamide Olaleye
#

@{

# Script module or binary module file associated with this manifest.
RootModule = 'EntraDeviceReport.psm1'

# Version number of this module.
ModuleVersion = '0.3.2'

# Supported PSEditions
CompatiblePSEditions = @('Core')

# ID used to uniquely identify this module
GUID = 'bf9454ac-5d27-47d9-b086-98764d7a9af9'

# Author of this module
Author = 'Olamide Olaleye'

# Company or vendor of this module
CompanyName = 'Fountview Solutions Limited'

# Copyright statement for this module
Copyright = '(c) Olamide Olaleye. All rights reserved.'

# Description of the functionality provided by this module
Description = 'Read-only Microsoft Entra ID unmanaged and stale device reporting via Microsoft Graph. Generates CSV evidence for device-exposure assessment findings; performs no writes to any tenant.'

# Minimum version of the PowerShell engine required by this module
PowerShellVersion = '7.4'

# Name of the PowerShell host required by this module
# PowerShellHostName = ''

# Minimum version of the PowerShell host required by this module
# PowerShellHostVersion = ''

# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# DotNetFrameworkVersion = ''

# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# ClrVersion = ''

# Processor architecture (None, X86, Amd64) required by this module
# ProcessorArchitecture = ''

# Modules that must be imported into the global environment prior to importing this module
RequiredModules = @()

# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()

# Script files (.ps1) that are run in the caller's environment prior to importing this module.
# ScriptsToProcess = @()

# Type files (.ps1xml) to be loaded when importing this module
# TypesToProcess = @()

# Format files (.ps1xml) to be loaded when importing this module
# FormatsToProcess = @()

# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
# NestedModules = @()

# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = @('Connect-EntraDeviceReport','Get-EntraUnmanagedDeviceReport')

# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
CmdletsToExport = @()

# Variables to export from this module
VariablesToExport = @()

# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
AliasesToExport = @()

# DSC resources to export from this module
DscResourcesToExport = @()

# List of all modules packaged with this module
# ModuleList = @()

# List of all files packaged with this module
# FileList = @()

# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
PrivateData = @{

    PSData = @{

        # Tags applied to this module. These help with module discovery in online galleries.
        # Tags = @()

        # A URL to the license for this module.
        # LicenseUri = ''

        # A URL to the main website for this project.
        # ProjectUri = ''

        # A URL to an icon representing this module.
        # IconUri = ''

        # Tags drive PowerShell Gallery discoverability. PSEdition_Core is the
        # conventional marker for a module that requires PowerShell 6 or later.
        Tags = @(
            'Entra', 'EntraID', 'AzureAD', 'MicrosoftGraph', 'Graph',
            'Device', 'DeviceManagement', 'Intune', 'Security', 'Compliance',
            'Audit', 'Reporting', 'ReadOnly', 'PSEdition_Core'
        )

        # A URL to the license for this module.
        LicenseUri = 'https://github.com/Princetimber/EntraDeviceReport/blob/main/LICENSE'

        # A URL to the main website for this project.
        ProjectUri = 'https://github.com/Princetimber/EntraDeviceReport'

        # ReleaseNotes of this module
        ReleaseNotes = '## [0.3.2] - 2026-09-09

### Added

- `Get-EntraUnmanagedDeviceReport`, the module''s single public function. Queries
  the Microsoft Graph `/devices` resource, classifies each registered device as
  managed or unmanaged, evaluates staleness against a configurable threshold
  (`-StaleAfterDays`, default 180), and returns report objects or writes a CSV.
  Supports `-Path`, `-IncludeManaged`, `-StaleOnly`, `-PassThru`, and
  `ShouldProcess` for the one filesystem side effect.
- Nine private helpers, one responsibility each: `Assert-GraphConnection`,
  `Get-EntraDeviceObject`, `Resolve-DeviceManagementState`, `Test-DeviceStale`,
  `ConvertTo-DeviceReportRow`, `Export-DeviceReportCsv`, `Get-DevicePropertyValue`,
  `ConvertTo-UtcDateTime`, and `Format-Iso8601Utc`.
- Eighteen-column report contract, emitted in a fixed order, covering device
  identity, the mutually exclusive `Managed`/`UnManaged` pair, ownership, trust
  type, platform, sign-in recency, and hybrid-join derivation.
- Categorised terminating errors with stable error IDs, so a missing scope
  (`PermissionDenied`), an expired session (`AuthenticationError`), throttling
  (`LimitsExceeded`), and a transport fault (`ConnectionError`) are
  distinguishable by an operator rather than surfacing identically.
- Pester v6 unit suite, one test file per function, hermetic and mocked
  throughout. Verified by mutation testing: fifteen deliberate defects injected
  into the source were each confirmed to turn the suite red.

### Added

- `Connect-EntraDeviceReport`, a second public function that wires authentication
  into the module. One parameter set per method, ordered by preference: managed
  identity (`-ManagedIdentity`, with optional `-ClientId` for a user-assigned
  identity), workload identity federation via a pre-acquired `-AccessToken`,
  certificate app-only (`-CertificateThumbprint`, `-CertificateSubjectName`, or
  a loaded `-Certificate`), `-EnvironmentVariable`, interactive browser (the
  default), and client secret as a last resort. `-Environment` supports
  sovereign clouds across every method.
- `-UseDeviceCode` as an **optional** switch on the interactive parameter set
  only. It is never mandatory and never required by another method; omitting it
  leaves interactive browser sign-in as the default. Using it emits a warning
  naming Conditional Access authentication-flows policies and the
  Microsoft-managed device code block as the reasons it may fail at sign-in.
- `-TenantId` is **mandatory on every authentication method**. `Connect-MgGraph`
  accepts it only on its interactive, certificate and client secret parameter
  sets, so for managed identity, access token and environment variable
  authentication the module verifies it against the resulting session instead
  and throws `GraphTenantMismatch` on a mismatch. Supply a GUID for that check
  to run; a domain name is reported as unverifiable on the verbose stream
  rather than passing silently.
- `Get-EntraDeviceReportScope`, the single source of truth for the module''s
  Graph permissions. Both the scope `Connect-EntraDeviceReport` requests and
  the scope `Assert-GraphConnection` validates now read from it, so the
  least-privilege requirement cannot drift between the two.
- `Assert-GraphTenant`, a private helper that confirms an established session
  belongs to the intended tenant.
- `Get-GraphConnectionParameter`, a private, side-effect-free helper that
  translates the chosen parameter set into the exact `Connect-MgGraph` argument
  set, so dispatch is testable without a network call.

`Get-EntraUnmanagedDeviceReport` still never authenticates. It inspects the
existing session and fails closed, so an unattended report run cannot trigger an
interactive prompt; authentication is `Connect-EntraDeviceReport`''s job alone.

### Fixed

- `Get-EntraUnmanagedDeviceReport` emitted `MethodException: Cannot find an
  overload for "Contains" and the argument count: "1"` once per device against a
  real tenant. `Get-DevicePropertyValue` called `.Contains($name)` on the
  device''s `AdditionalProperties`, which the Graph SDK populates with a
  `Dictionary[string, object]`. That type''s only single-argument `Contains` is
  the explicit non-generic `IDictionary` implementation, which PowerShell will
  not dispatch to; casting to `[System.Collections.IDictionary]` does not help
  either. Now uses `ContainsKey`, which both that type and a plain hashtable
  expose.

  **Any report produced by 0.2.0 to 0.3.1 against a live tenant should be
  re-run.** The open-type fallback threw for every device, so any property not
  present on the typed SDK model resolved to `$null`, which can misreport
  `ManagementType`, `Ownership`, and therefore the `Managed`/`UnManaged` split.

  The tests did not catch this because the fixture built the bag as a PowerShell
  hashtable, which *does* have `Contains(object)`. `Get-TestDevice` now builds a
  `Dictionary[string, object]` by default, matching the SDK, with
  `-AsHashtableBag` retained so both shapes stay covered.


- `-UseDeviceCode` never displayed the authentication URL and code, so the flow
  sat silent until it failed with "Authentication timed out after 120 seconds
  due to inactivity". `Connect-MgGraph` writes the device code prompt to its
  **success stream**, and the wrapper piped that to `Out-Null`. It is now
  relayed to the information stream with `-InformationAction Continue`, which
  puts the prompt on the console without letting the connection banner leak
  into the function''s own return value. Verified against the live SDK:
  `Connect-MgGraph -UseDeviceCode | Out-Null` emits nothing at all, while the
  same call without the pipe prints the code. `-NoWelcome` was ruled out as a
  cause; it suppresses only the welcome banner.
- Failure and verbose messages reported the method as `Interactive` during a
  device code sign-in, because that is the parameter set name. They now say
  `Interactive (device code)`.


- Unit tests now run on a clean machine without the Microsoft Graph SDK
  installed. Pester''s `Mock` requires the target command to exist, and command
  lookup from module scope falls back to global scope, so `tests/TestHelpers.ps1`
  defines global stubs for `Get-MgContext`, `Get-MgDevice`, and `Connect-MgGraph`
  when the real cmdlets are absent. Without this, every mocked Graph call threw
  `CommandNotFoundException` on CI runners while passing on a developer machine.
- Rewrote `tests/QA/repository.tests.ps1`, which had an unbalanced brace that
  made Pester discovery fail for the whole file. A discovery failure is not
  counted in Pester''s `FailedCount`, so the build reported success while an
  entire QA file never ran.

### Security


- The module is read-only. It reads the Graph session, reads device objects,
  and optionally writes one local CSV. No tenant object is created, modified,
  retired, or deleted.
- Authentication is never initiated by the module. It inspects the existing
  session via `Get-MgContext` and fails closed with a named-scope error,
  so an unattended run cannot trigger an interactive prompt.
- Least-privilege scope is `Device.Read.All`; `Directory.Read.All` is accepted
  only as a documented broader alternative.
- No user principal names, owner identities, IP addresses, or location data
  are emitted. Only device inventory attributes appear in the report.
'


        # Prerelease string of this module
        Prerelease =  ''

        # Flag to indicate whether the module requires explicit user acceptance for install/update/save
        # RequireLicenseAcceptance = $false

        # External dependent modules of this module
        # ExternalModuleDependencies = @()

    } # End of PSData hashtable

} # End of PrivateData hashtable

# HelpInfo URI of this module
# HelpInfoURI = ''

# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
# DefaultCommandPrefix = ''

}