From 7b4bceeb7a6a83a35233411234b537899b5040b6 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:27:31 +0200 Subject: [PATCH 1/8] Centralize Microsoft Graph beta transport (#136) --- .../Private/Get-AssignmentFailures.ps1 | 15 +- .../Private/Get-AssignmentFilterLookup.ps1 | 14 +- .../Private/Get-DeviceInfo.ps1 | 2 +- .../Private/Get-GroupInfo.ps1 | 12 +- .../Private/Get-GroupMemberships.ps1 | 11 +- .../Private/Get-IntuneAssignments.ps1 | 12 +- .../Private/Get-IntuneEntities.ps1 | 35 +-- .../Private/Get-ScopeTagLookup.ps1 | 10 +- .../Private/Get-TransitiveGroupMembership.ps1 | 20 +- .../Private/Get-UserInfo.ps1 | 4 +- .../Private/Invoke-IACGraphRequest.ps1 | 251 +++++++++++++++++ .../Private/Invoke-IntuneCategoryScan.ps1 | 11 +- .../Private/Switch-Tenant.ps1 | 2 +- .../Public/Compare-IntuneGroupAssignment.ps1 | 15 +- .../Connect-IntuneAssignmentChecker.ps1 | 2 +- .../Public/Get-IntuneDeviceAssignment.ps1 | 2 +- .../Public/Get-IntuneEmptyGroup.ps1 | 4 +- .../Public/Get-IntuneGroupAssignment.ps1 | 4 +- .../Public/Get-IntuneUnassignedPolicy.ps1 | 22 +- .../Public/Get-IntuneUserDeviceAssignment.ps1 | 16 +- .../Public/Search-IntuneSetting.ps1 | 28 +- .../Public/Test-IntuneGroupMembership.ps1 | 4 +- .../Public/Test-IntuneGroupRemoval.ps1 | 4 +- .../Public/Update-IntuneSettingDefinition.ps1 | 26 +- .../IntuneAssignmentChecker/html-export.ps1 | 14 +- README.md | 4 + Tests/Unit/CategoryScan.Tests.ps1 | 12 +- Tests/Unit/CompareGroupAssignment.Tests.ps1 | 18 +- Tests/Unit/Connection.Tests.ps1 | 4 +- Tests/Unit/DeviceAssignment.Tests.ps1 | 18 +- Tests/Unit/GraphMembership.Tests.ps1 | 20 +- Tests/Unit/GraphTransport.Tests.ps1 | 264 ++++++++++++++++++ Tests/Unit/GroupAssignment.Tests.ps1 | 22 +- Tests/Unit/GroupInfo.Tests.ps1 | 14 +- Tests/Unit/HtmlReportCsv.Tests.ps1 | 8 +- .../ImportedAdministrativeTemplates.Tests.ps1 | 20 +- Tests/Unit/MobileAppScopeTags.Tests.ps1 | 6 +- Tests/Unit/TestGroupMembership.Tests.ps1 | 10 +- Tests/Unit/TestGroupRemoval.Tests.ps1 | 8 +- Tests/Unit/UserAssignment.Tests.ps1 | 6 +- 40 files changed, 694 insertions(+), 280 deletions(-) create mode 100644 Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 create mode 100644 Tests/Unit/GraphTransport.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFailures.ps1 b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFailures.ps1 index 523caac..6357d1c 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFailures.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFailures.ps1 @@ -40,11 +40,11 @@ function Get-AssignmentFailures { $uri = "$script:GraphEndpoint/beta/deviceManagement/reports/getMobileApplicationManagementAppStatusReport" $response = try { - Invoke-MgGraphRequest -Uri $uri -Method POST -Body $reportBody + Invoke-IACGraphRequest -Uri $uri -Method POST -Body $reportBody } catch { # If the new endpoint fails, try the alternative endpoint $uri = "$script:GraphEndpoint/beta/deviceManagement/reports/getAppStatusOverviewReport" - Invoke-MgGraphRequest -Uri $uri -Method POST -Body $reportBody + Invoke-IACGraphRequest -Uri $uri -Method POST -Body $reportBody } if ($response.values) { @@ -92,7 +92,7 @@ function Get-AssignmentFailures { } | ConvertTo-Json $uri = "$script:GraphEndpoint/beta/deviceManagement/reports/getConfigurationPolicyDevicesReport" - $response = Invoke-MgGraphRequest -Uri $uri -Method POST -Body $reportBody + $response = Invoke-IACGraphRequest -Uri $uri -Method POST -Body $reportBody if ($response.values) { $failures = $response.values | Where-Object { @@ -124,15 +124,8 @@ function Get-AssignmentFailures { $compliancePolicies = Get-IntuneEntities -EntityType "deviceCompliancePolicies" foreach ($policy in $compliancePolicies) { - $statuses = [System.Collections.ArrayList]::new() $statusUri = "$script:GraphEndpoint/beta/deviceManagement/deviceCompliancePolicies('$($policy.id)')/deviceStatuses" - do { - $statusResponse = Invoke-MgGraphRequest -Uri $statusUri -Method GET - if ($statusResponse -and $null -ne $statusResponse.value) { - $statuses.AddRange([object[]]$statusResponse.value) - } - $statusUri = $statusResponse.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($statusUri)) + $statuses = @((Invoke-IACGraphRequest -Uri $statusUri -Method GET).value) $failures = $statuses | Where-Object { $_.status -in @("error", "conflict", "notApplicable", "nonCompliant") diff --git a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 index 71bcc19..4bec10f 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 @@ -4,16 +4,12 @@ function Get-AssignmentFilterLookup { $lookup = @{} try { $uri = "$script:GraphEndpoint/beta/deviceManagement/assignmentFilters?`$select=id,displayName,platform" - do { - $response = Invoke-MgGraphRequest -Uri $uri -Method Get - foreach ($filter in $response.value) { - $lookup["$($filter.id)"] = [PSCustomObject]@{ - Name = $filter.displayName - Platform = $filter.platform - } + foreach ($filter in @((Invoke-IACGraphRequest -Uri $uri -Method Get).value)) { + $lookup["$($filter.id)"] = [PSCustomObject]@{ + Name = $filter.displayName + Platform = $filter.platform } - $uri = $response.'@odata.nextLink' - } while ($uri) + } } catch { Write-Warning "Could not fetch assignment filters: $($_.Exception.Message)" diff --git a/Module/IntuneAssignmentChecker/Private/Get-DeviceInfo.ps1 b/Module/IntuneAssignmentChecker/Private/Get-DeviceInfo.ps1 index 4dcfdb5..7816371 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-DeviceInfo.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-DeviceInfo.ps1 @@ -9,7 +9,7 @@ function Get-DeviceInfo { $escapedName = $DeviceName -replace "'", "''" $deviceUri = "$script:GraphEndpoint/beta/devices?`$filter=displayName eq '$escapedName'&`$select=$selectProps" try { - $deviceResponse = Invoke-MgGraphRequest -Uri $deviceUri -Method Get + $deviceResponse = Invoke-IACGraphRequest -Uri $deviceUri -Method Get } catch { return @{ diff --git a/Module/IntuneAssignmentChecker/Private/Get-GroupInfo.ps1 b/Module/IntuneAssignmentChecker/Private/Get-GroupInfo.ps1 index c2ea40b..cf98e70 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-GroupInfo.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-GroupInfo.ps1 @@ -14,8 +14,8 @@ function Get-GroupInfo { try { $selectProperties = 'id,displayName,groupTypes,mailEnabled,securityEnabled,mail' - $groupUri = "$script:GraphEndpoint/v1.0/groups/$GroupId`?`$select=$selectProperties" - $group = Invoke-MgGraphRequest -Uri $groupUri -Method Get + $groupUri = "$script:GraphEndpoint/beta/groups/$GroupId`?`$select=$selectProperties" + $group = Invoke-IACGraphRequest -Uri $groupUri -Method Get $result = ConvertTo-IntuneGroupInfo -Group $group if (-not $result.Success) { throw "Microsoft Graph returned a group response without an Object ID." @@ -23,14 +23,16 @@ function Get-GroupInfo { } catch { $errorMessage = $_.Exception.Message - $statusCode = if ($_.Exception.Response -and $null -ne $_.Exception.Response.StatusCode) { + $statusCode = if ($_.Exception.Data.Contains('StatusCode')) { + $_.Exception.Data['StatusCode'] + } + elseif ($_.Exception.Response -and $null -ne $_.Exception.Response.StatusCode) { [int]$_.Exception.Response.StatusCode } else { $null } - # Invoke-MgGraphRequest does not consistently expose Response.StatusCode, - # so also recognize the standard message-only not-found shapes. + # Fall back to message recognition for non-transport collaborators and older SDK errors. $isNotFound = $statusCode -eq 404 -or $errorMessage -match '(?i)\b404\b|Not\s*Found|Request_ResourceNotFound' if (-not $isNotFound) { diff --git a/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 b/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 index 0411b5b..7f11c0a 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 @@ -10,16 +10,11 @@ function Get-GroupMemberships { ) $memberships = [System.Collections.ArrayList]::new() - $uri = "$script:GraphEndpoint/v1.0/$($ObjectType.ToLower())s/$ObjectId/transitiveMemberOf?`$select=id,displayName" + $uri = "$script:GraphEndpoint/beta/$($ObjectType.ToLower())s/$ObjectId/transitiveMemberOf?`$select=id,displayName" try { - do { - $response = Invoke-MgGraphRequest -Uri $uri -Method Get - if ($response -and $null -ne $response.value) { - $memberships.AddRange([object[]]$response.value) - } - $uri = $response.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($uri)) + $pagedMemberships = @((Invoke-IACGraphRequest -Uri $uri -Method Get).value) + if ($pagedMemberships.Count -gt 0) { $memberships.AddRange([object[]]$pagedMemberships) } return $memberships } catch { diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 index 4d1f566..573baf2 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 @@ -27,7 +27,7 @@ function Get-IntuneAssignments { # For generic App Protection Policies, determine the specific policy type first $policyDetailsUri = "$script:GraphEndpoint/beta/deviceAppManagement/managedAppPolicies/$EntityId" try { - $policyDetailsResponse = Invoke-MgGraphRequest -Uri $policyDetailsUri -Method Get + $policyDetailsResponse = Invoke-IACGraphRequest -Uri $policyDetailsUri -Method Get $actualAssignmentsUri = Get-AppProtectionAssignmentUri -Policy $policyDetailsResponse if (-not $actualAssignmentsUri) { Write-Warning "Could not determine specific App Protection Policy type for $EntityId from OData type '$($policyDetailsResponse.'@odata.type')'." @@ -69,15 +69,7 @@ function Get-IntuneAssignments { $assignmentsToReturn = [System.Collections.ArrayList]::new() try { - $allAssignmentsForEntity = [System.Collections.ArrayList]::new() - $currentAssignmentsPageUri = $actualAssignmentsUri - do { - $pagedAssignmentResponse = Invoke-MgGraphRequest -Uri $currentAssignmentsPageUri -Method Get - if ($pagedAssignmentResponse -and $null -ne $pagedAssignmentResponse.value) { - $allAssignmentsForEntity.AddRange($pagedAssignmentResponse.value) - } - $currentAssignmentsPageUri = $pagedAssignmentResponse.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($currentAssignmentsPageUri)) + $allAssignmentsForEntity = @((Invoke-IACGraphRequest -Uri $actualAssignmentsUri -Method Get).value) # Ensure $allAssignmentsForEntity is not null before trying to iterate $assignmentList = if ($allAssignmentsForEntity) { $allAssignmentsForEntity } else { @() } diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 index c2c4373..ba825fa 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 @@ -31,31 +31,20 @@ function Get-IntuneEntities { $entities = [System.Collections.ArrayList]::new() # Initialize as ArrayList - do { - try { - $response = Invoke-MgGraphRequest -Uri $currentUri -Method Get -ErrorAction Stop - if ($null -ne $response -and $null -ne $response.value) { - if ($response.value -is [array]) { - $entities.AddRange($response.value) - } - else { - $entities.Add($response.value) - } - } - $currentUri = $response.'@odata.nextLink' + try { + $pagedEntities = @((Invoke-IACGraphRequest -Uri $currentUri -Method Get -ErrorAction Stop).value) + if ($pagedEntities.Count -gt 0) { $entities.AddRange([object[]]$pagedEntities) } + } + catch { + $errorMessage = $_.Exception.Message + $statusCode = if ($_.Exception.Data.Contains('StatusCode')) { $_.Exception.Data['StatusCode'] } else { $null } + if ($statusCode -eq 403 -or $errorMessage -match "403|Forbidden|Authorization_RequestDenied") { + Write-Warning "Permission denied (403) for '$EntityType'. Ensure admin consent has been granted for the required Graph API permissions. Run 'Connect-MgGraph -Scopes ...' with the necessary scopes or grant admin consent in Azure AD." } - catch { - $errorMessage = $_.Exception.Message - $statusCode = $_.Exception.Response.StatusCode.value__ - if ($statusCode -eq 403 -or $errorMessage -match "403|Forbidden|Authorization_RequestDenied") { - Write-Warning "Permission denied (403) for '$EntityType'. Ensure admin consent has been granted for the required Graph API permissions. Run 'Connect-MgGraph -Scopes ...' with the necessary scopes or grant admin consent in Azure AD." - } - else { - Write-Warning "Error fetching entities for $EntityType from $currentUri : $errorMessage" - } - $currentUri = $null # Stop pagination on error + else { + Write-Warning "Error fetching entities for ${EntityType}: $errorMessage" } - } while ($currentUri) + } return $entities } diff --git a/Module/IntuneAssignmentChecker/Private/Get-ScopeTagLookup.ps1 b/Module/IntuneAssignmentChecker/Private/Get-ScopeTagLookup.ps1 index 6aa301a..48f5ffe 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-ScopeTagLookup.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-ScopeTagLookup.ps1 @@ -4,13 +4,9 @@ function Get-ScopeTagLookup { $lookup = @{ "0" = "Default" } try { $uri = "$script:GraphEndpoint/beta/deviceManagement/roleScopeTags?`$select=id,displayName" - do { - $response = Invoke-MgGraphRequest -Uri $uri -Method Get - foreach ($tag in $response.value) { - $lookup["$($tag.id)"] = $tag.displayName - } - $uri = $response.'@odata.nextLink' - } while ($uri) + foreach ($tag in @((Invoke-IACGraphRequest -Uri $uri -Method Get).value)) { + $lookup["$($tag.id)"] = $tag.displayName + } } catch { Write-Warning "Could not fetch scope tags: $($_.Exception.Message)" diff --git a/Module/IntuneAssignmentChecker/Private/Get-TransitiveGroupMembership.ps1 b/Module/IntuneAssignmentChecker/Private/Get-TransitiveGroupMembership.ps1 index 1d579bc..63e997c 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-TransitiveGroupMembership.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-TransitiveGroupMembership.ps1 @@ -6,21 +6,15 @@ function Get-TransitiveGroupMembership { ) $parentGroups = [System.Collections.ArrayList]::new() - $uri = "$script:GraphEndpoint/v1.0/groups/$GroupId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName" + $uri = "$script:GraphEndpoint/beta/groups/$GroupId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName" try { - do { - $response = Invoke-MgGraphRequest -Uri $uri -Method Get - if ($response -and $null -ne $response.value) { - foreach ($group in $response.value) { - $null = $parentGroups.Add([PSCustomObject]@{ - id = $group.id - displayName = $group.displayName - }) - } - } - $uri = $response.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($uri)) + foreach ($group in @((Invoke-IACGraphRequest -Uri $uri -Method Get).value)) { + $null = $parentGroups.Add([PSCustomObject]@{ + id = $group.id + displayName = $group.displayName + }) + } } catch { Write-Warning "Error fetching parent group memberships for group '$GroupId': $($_.Exception.Message)" diff --git a/Module/IntuneAssignmentChecker/Private/Get-UserInfo.ps1 b/Module/IntuneAssignmentChecker/Private/Get-UserInfo.ps1 index dc1df18..7f94037 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-UserInfo.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-UserInfo.ps1 @@ -6,8 +6,8 @@ function Get-UserInfo { ) try { - $userUri = "$script:GraphEndpoint/v1.0/users/$([uri]::EscapeDataString($UserPrincipalName))" - $user = Invoke-MgGraphRequest -Uri $userUri -Method Get + $userUri = "$script:GraphEndpoint/beta/users/$([uri]::EscapeDataString($UserPrincipalName))" + $user = Invoke-IACGraphRequest -Uri $userUri -Method Get return @{ Id = $user.id UserPrincipalName = $user.userPrincipalName diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 new file mode 100644 index 0000000..b856127 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 @@ -0,0 +1,251 @@ +function Invoke-IACGraphRequest { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$Uri, + + [Parameter()] + [ValidateSet('GET', 'POST')] + [string]$Method = 'GET', + + [Parameter()] + [AllowNull()] + [object]$Body, + + [Parameter()] + [switch]$AllPages, + + [Parameter()] + [ValidateRange(0, 10)] + [int]$MaxRetryCount = 3, + + [Parameter()] + [ValidateRange(1, 10000)] + [int]$MaxPageCount = 1000 + ) + + if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) { + throw 'Microsoft Graph is not connected. Run Connect-IntuneAssignmentChecker first.' + } + + $graphBase = $script:GraphEndpoint.TrimEnd('/') + $requestUri = $Uri.Trim() + + # OData filters commonly contain literal spaces. IsWellFormedUriString rejects those, + # so detect the scheme first and validate a safely escaped copy of the authority. + $isAbsolute = $requestUri -match '^[a-z][a-z0-9+.-]*://' + if ($isAbsolute) { + $parsedRequestUri = $null + $parseCandidate = $requestUri -replace ' ', '%20' + if (-not [uri]::TryCreate($parseCandidate, [System.UriKind]::Absolute, [ref]$parsedRequestUri)) { + throw 'Graph request URI is not a valid absolute URI.' + } + $parsedGraphBase = [uri]$graphBase + if ($parsedRequestUri.Scheme -ne $parsedGraphBase.Scheme -or + $parsedRequestUri.Host -ne $parsedGraphBase.Host -or + $parsedRequestUri.Port -ne $parsedGraphBase.Port -or + -not $requestUri.StartsWith("$graphBase/", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Graph request URI '$requestUri' does not match the active cloud endpoint '$graphBase'." + } + + $relativePath = $requestUri.Substring($graphBase.Length) + } + else { + $relativePath = if ($requestUri.StartsWith('/')) { $requestUri } else { "/$requestUri" } + } + + # The module intentionally targets Microsoft Graph beta. Normalize callers and nextLink + # values through the same path so a stale version cannot silently bypass this policy. + $relativePath = $relativePath -replace '^/(?:v1\.0|beta)(?=/|$)', '' + $currentUri = "$graphBase/beta$relativePath" + $items = [System.Collections.Generic.List[object]]::new() + $visitedPageUris = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $pageCount = 0 + $includeBody = $PSBoundParameters.ContainsKey('Body') + $firstResponse = $null + + do { + $pageCount++ + if ($pageCount -gt $MaxPageCount) { + throw "Microsoft Graph paging exceeded the configured maximum of $MaxPageCount pages." + } + if (-not $visitedPageUris.Add($currentUri)) { + throw 'Microsoft Graph returned a repeated nextLink; paging was stopped to prevent an infinite loop.' + } + + $attempt = 0 + while ($true) { + try { + $requestParameters = @{ + Uri = $currentUri + Method = $Method + ErrorAction = 'Stop' + } + if ($includeBody) { + $requestParameters['Body'] = $Body + } + + $response = Invoke-MgGraphRequest @requestParameters + break + } + catch { + $attempt++ + $statusCode = $null + foreach ($candidate in @( + $_.Exception.Response.StatusCode, + $_.Exception.StatusCode, + $_.Exception.ResponseStatusCode + )) { + if ($null -eq $candidate) { continue } + try { + $statusCode = [int]$candidate + break + } + catch { } + } + if ($null -eq $statusCode) { + foreach ($pattern in @( + '(?i)\bHTTP(?:\s+status)?\s+(?4\d\d|5\d\d)\b', + '(?i)response status code does not indicate success:\s*(?4\d\d|5\d\d)\b' + )) { + if ($_.Exception.Message -match $pattern) { + $statusCode = [int]$Matches.Status + break + } + } + } + + $retryAfterSeconds = $null + try { + $retryAfterHeader = $_.Exception.Response.Headers.'Retry-After' + if ($retryAfterHeader -is [System.Collections.IEnumerable] -and $retryAfterHeader -isnot [string]) { + $retryAfterHeader = @($retryAfterHeader)[0] + } + if ($retryAfterHeader) { $retryAfterSeconds = [int]$retryAfterHeader } + } + catch { } + if ($null -eq $retryAfterSeconds) { + try { + $delta = $_.Exception.Response.Headers.RetryAfter.Delta + if ($delta) { $retryAfterSeconds = [int][math]::Ceiling($delta.TotalSeconds) } + } + catch { } + } + + $baseException = $_.Exception.GetBaseException() + $isConnectionTransient = $null -eq $statusCode -and ( + $baseException -is [System.TimeoutException] -or + $baseException -is [System.Net.Http.HttpRequestException] -or + $baseException -is [System.Net.WebException] -or + $baseException -is [System.Threading.Tasks.TaskCanceledException] + ) + $isTransient = $statusCode -eq 429 -or + ($null -ne $statusCode -and $statusCode -ge 500 -and $statusCode -le 599) -or + $isConnectionTransient + if ($isTransient -and $attempt -le $MaxRetryCount) { + $delaySeconds = if ($null -ne $retryAfterSeconds -and $retryAfterSeconds -ge 0) { + $retryAfterSeconds + } + else { + [math]::Min([math]::Pow(2, $attempt - 1), 30) + } + Write-Verbose "Microsoft Graph returned HTTP $statusCode for '$currentUri'. Retrying in $delaySeconds second(s) (attempt $attempt of $MaxRetryCount)." + if ($delaySeconds -gt 0) { Start-Sleep -Seconds $delaySeconds } + continue + } + + $graphErrorCode = $null + $graphErrorMessage = $_.Exception.Message + $requestId = $null + $clientRequestId = $null + $errorPayload = $_.ErrorDetails.Message + if (-not [string]::IsNullOrWhiteSpace($errorPayload)) { + try { + $parsedError = $errorPayload | ConvertFrom-Json -Depth 20 -ErrorAction Stop + $graphError = if ($parsedError.error) { $parsedError.error } else { $parsedError } + if ($graphError.code) { $graphErrorCode = [string]$graphError.code } + if ($graphError.message) { $graphErrorMessage = [string]$graphError.message } + $innerError = $graphError.innerError + if ($innerError) { + $requestId = $innerError.'request-id' + $clientRequestId = $innerError.'client-request-id' + } + } + catch { } + } + + $statusText = if ($null -ne $statusCode) { "HTTP $statusCode" } else { 'HTTP status unavailable' } + $codeText = if ($graphErrorCode) { " ($graphErrorCode)" } else { '' } + $message = "Microsoft Graph request failed: $statusText$codeText - $graphErrorMessage" + $exception = [System.InvalidOperationException]::new($message, $_.Exception) + $exception.Data['StatusCode'] = $statusCode + $exception.Data['GraphErrorCode'] = $graphErrorCode + $exception.Data['RequestId'] = $requestId + $exception.Data['ClientRequestId'] = $clientRequestId + $exception.Data['RequestUri'] = $currentUri + $exception.Data['Method'] = $Method + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'IntuneAssignmentChecker.GraphRequestFailed', + [System.Management.Automation.ErrorCategory]::InvalidOperation, + $currentUri + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + } + + if ($null -eq $firstResponse) { $firstResponse = $response } + + $nextLink = if ($response) { $response.'@odata.nextLink' } else { $null } + if (-not $AllPages -and $pageCount -eq 1 -and [string]::IsNullOrWhiteSpace($nextLink)) { + return $response + } + + if ($response -and $null -ne $response.value) { + foreach ($item in @($response.value)) { $items.Add($item) } + } + elseif ($AllPages -and $null -ne $response) { + $items.Add($response) + } + + if (-not [string]::IsNullOrWhiteSpace($nextLink)) { + if (-not $nextLink.StartsWith("$graphBase/beta/", [System.StringComparison]::OrdinalIgnoreCase)) { + $exception = [System.Security.SecurityException]::new('Microsoft Graph returned a nextLink outside the active beta endpoint.') + $exception.Data['StatusCode'] = $null + $exception.Data['RequestUri'] = $nextLink + $exception.Data['Method'] = 'GET' + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'IntuneAssignmentChecker.InvalidGraphNextLink', + [System.Management.Automation.ErrorCategory]::SecurityError, + $nextLink + ) + $PSCmdlet.ThrowTerminatingError($errorRecord) + } + $currentUri = $nextLink + $Method = 'GET' + $includeBody = $false + } + } while (-not [string]::IsNullOrWhiteSpace($currentUri) -and -not [string]::IsNullOrWhiteSpace($nextLink)) + + if ($AllPages) { + return , $items.ToArray() + } + + # Preserve the normal response contract while replacing the first-page value with the + # complete collection. This makes paging automatic for existing response.value callers. + $combinedResponse = [ordered]@{} + if ($firstResponse -is [System.Collections.IDictionary]) { + foreach ($key in $firstResponse.Keys) { + if ($key -notin @('value', '@odata.nextLink')) { $combinedResponse[$key] = $firstResponse[$key] } + } + } + else { + foreach ($property in $firstResponse.PSObject.Properties) { + if ($property.Name -notin @('value', '@odata.nextLink')) { $combinedResponse[$property.Name] = $property.Value } + } + } + $combinedResponse['value'] = $items.ToArray() + return [PSCustomObject]$combinedResponse +} diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index d3c7683..01d139c 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -56,16 +56,7 @@ function Invoke-IntuneCategoryScan { function Get-PagedGraphValue { param([string]$Uri) - $items = [System.Collections.Generic.List[object]]::new() - $currentUri = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $currentUri -Method Get - if ($response -and $null -ne $response.value) { - $items.AddRange(@($response.value)) - } - $currentUri = $response.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($currentUri)) - return , $items + return , @((Invoke-IACGraphRequest -Uri $Uri -Method Get).value) } # Normalizes raw Graph assignment objects to the standard record shape, reproducing diff --git a/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 b/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 index 6632c35..cef0895 100644 --- a/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 @@ -39,7 +39,7 @@ function Switch-Tenant { # Try to get tenant display name try { - $org = Invoke-MgGraphRequest -Method GET -Uri "$script:GraphEndpoint/v1.0/organization" -ErrorAction SilentlyContinue + $org = Invoke-IACGraphRequest -Method GET -Uri "$script:GraphEndpoint/beta/organization" -ErrorAction SilentlyContinue if ($org.value -and $org.value.Count -gt 0) { $script:CurrentTenantName = $org.value[0].displayName } diff --git a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 index 3f40bb3..2df630e 100644 --- a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 @@ -139,8 +139,8 @@ function Compare-IntuneGroupAssignment { if ($groupInput -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { try { # Get group info from Graph API - $groupUri = "$script:GraphEndpoint/v1.0/groups/$groupInput`?`$select=$groupSelect" - $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get + $groupUri = "$script:GraphEndpoint/beta/groups/$groupInput`?`$select=$groupSelect" + $groupResponse = Invoke-IACGraphRequest -Uri $groupUri -Method Get $resolvedGroupInfo = ConvertTo-IntuneGroupInfo -Group $groupResponse if (-not $resolvedGroupInfo.Success) { Write-Host "The group lookup for '$groupInput' returned an invalid response without an Object ID." -ForegroundColor Red @@ -159,8 +159,8 @@ function Compare-IntuneGroupAssignment { else { # Try to find group by display name (single quotes escaped for the OData filter) $escapedGroupName = $groupInput -replace "'", "''" - $groupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedGroupName'&`$select=$groupSelect" - $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get + $groupUri = "$script:GraphEndpoint/beta/groups?`$filter=displayName eq '$escapedGroupName'&`$select=$groupSelect" + $groupResponse = Invoke-IACGraphRequest -Uri $groupUri -Method Get if ($groupResponse.value.Count -eq 0) { Write-Host "No group found with name: $groupInput" -ForegroundColor Red @@ -210,12 +210,7 @@ function Compare-IntuneGroupAssignment { } foreach ($shellScript in $entityCache['deviceShellScripts']) { $assignmentsUri = "$script:GraphEndpoint/beta/deviceManagement/deviceShellScripts('$($shellScript.id)')/groupAssignments" - $shellAssignments = [System.Collections.Generic.List[object]]::new() - do { - $assignmentResponse = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get - if ($assignmentResponse -and $null -ne $assignmentResponse.value) { $shellAssignments.AddRange(@($assignmentResponse.value)) } - $assignmentsUri = $assignmentResponse.'@odata.nextLink' - } while (![string]::IsNullOrEmpty($assignmentsUri)) + $shellAssignments = @((Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get).value) $hasAssignment = @($shellAssignments | Where-Object { $allGroupIds -contains $_.targetGroupId }) if ($hasAssignment.Count -gt 0) { diff --git a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 index 1738237..924c705 100644 --- a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 @@ -197,7 +197,7 @@ function Connect-IntuneAssignmentChecker { $script:CurrentUserUPN = $context.Account try { - $org = Invoke-MgGraphRequest -Method GET -Uri "$script:GraphEndpoint/v1.0/organization" -ErrorAction SilentlyContinue + $org = Invoke-IACGraphRequest -Method GET -Uri "$script:GraphEndpoint/beta/organization" -ErrorAction SilentlyContinue if ($org.value -and $org.value.Count -gt 0) { $script:CurrentTenantName = $org.value[0].displayName } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 index d23bfe9..10f5430 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 @@ -116,7 +116,7 @@ function Get-IntuneDeviceAssignment { if ($deviceName -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { try { $selectProps = "id,displayName,operatingSystem,operatingSystemVersion,managementType,deviceOwnership,trustType,isCompliant,isManaged,approximateLastSignInDateTime,manufacturer,model,enrollmentProfileName" - $directDevice = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/devices/$($deviceName)?`$select=$selectProps" -Method Get + $directDevice = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/devices/$($deviceName)?`$select=$selectProps" -Method Get $deviceInfo = @{ Id = $directDevice.id DisplayName = $directDevice.displayName diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 index 1bbaecb..56d9716 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 @@ -19,8 +19,8 @@ function Get-IntuneEmptyGroup { ) try { - $membersUri = "$script:GraphEndpoint/v1.0/groups/$GroupId/members?`$select=id" - $response = Invoke-MgGraphRequest -Uri $membersUri -Method Get + $membersUri = "$script:GraphEndpoint/beta/groups/$GroupId/members?`$select=id" + $response = Invoke-IACGraphRequest -Uri $membersUri -Method Get return $response.value.Count -eq 0 } catch { diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 index 4705788..827658b 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 @@ -77,8 +77,8 @@ function Get-IntuneGroupAssignment { # Try to find group by display name (single quotes escaped for the OData filter) $escapedGroupName = $groupInput -replace "'", "''" $groupSelect = 'id,displayName,groupTypes,mailEnabled,securityEnabled,mail' - $groupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedGroupName'&`$select=$groupSelect" - $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get + $groupUri = "$script:GraphEndpoint/beta/groups?`$filter=displayName eq '$escapedGroupName'&`$select=$groupSelect" + $groupResponse = Invoke-IACGraphRequest -Uri $groupUri -Method Get if ($groupResponse.value.Count -eq 0) { Write-Host "No group found with name: $groupInput" -ForegroundColor Red diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 index 38e56d1..be1ce18 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 @@ -77,7 +77,7 @@ function Get-IntuneUnassignedPolicy { if ($assignmentsUri) { try { - $assignmentResponse = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get + $assignmentResponse = Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get if ($assignmentResponse.value.Count -eq 0) { $unassignedPolicies.AppProtectionPolicies += $policy } @@ -126,7 +126,7 @@ function Get-IntuneUnassignedPolicy { if ($antivirusPolicies) { foreach ($policy in $antivirusPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.AntivirusProfiles += $policy } @@ -146,7 +146,7 @@ function Get-IntuneUnassignedPolicy { if ($diskEncryptionPolicies) { foreach ($policy in $diskEncryptionPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.DiskEncryptionProfiles += $policy } @@ -166,7 +166,7 @@ function Get-IntuneUnassignedPolicy { if ($firewallPolicies) { foreach ($policy in $firewallPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.FirewallProfiles += $policy } @@ -186,7 +186,7 @@ function Get-IntuneUnassignedPolicy { if ($edrPolicies) { foreach ($policy in $edrPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.EndpointDetectionProfiles += $policy } @@ -206,7 +206,7 @@ function Get-IntuneUnassignedPolicy { if ($asrPolicies) { foreach ($policy in $asrPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.AttackSurfaceProfiles += $policy } @@ -226,7 +226,7 @@ function Get-IntuneUnassignedPolicy { if ($accountProtectionPolicies) { foreach ($policy in $accountProtectionPolicies) { try { - $assignments = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignments = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get if ($assignments.value.Count -eq 0) { $unassignedPolicies.AccountProtectionProfiles += $policy } @@ -243,12 +243,8 @@ function Get-IntuneUnassignedPolicy { $unassignedAppUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq false&`$select=id,displayName,roleScopeTagIds" $unassignedApps = [System.Collections.Generic.List[object]]::new() try { - $unassignedAppResponse = Invoke-MgGraphRequest -Uri $unassignedAppUri -Method Get - if ($unassignedAppResponse.value) { $unassignedApps.AddRange([object[]]$unassignedAppResponse.value) } - while ($unassignedAppResponse.'@odata.nextLink') { - $unassignedAppResponse = Invoke-MgGraphRequest -Uri $unassignedAppResponse.'@odata.nextLink' -Method Get - if ($unassignedAppResponse.value) { $unassignedApps.AddRange([object[]]$unassignedAppResponse.value) } - } + $pagedApps = @((Invoke-IACGraphRequest -Uri $unassignedAppUri -Method Get).value) + if ($pagedApps.Count -gt 0) { $unassignedApps.AddRange([object[]]$pagedApps) } } catch { Write-Host "Error fetching unassigned applications: $($_.Exception.Message)" -ForegroundColor Red diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 index ccc1fef..43dd66a 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 @@ -65,7 +65,7 @@ function Get-IntuneUserDeviceAssignment { if ($devName -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { try { $selectProps = "id,displayName,operatingSystem,operatingSystemVersion" - $directDevice = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/devices/$($devName)?`$select=$selectProps" -Method Get + $directDevice = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/devices/$($devName)?`$select=$selectProps" -Method Get $deviceInfo = @{ Id = $directDevice.id DisplayName = $directDevice.displayName @@ -209,7 +209,7 @@ function Get-IntuneUserDeviceAssignment { foreach ($policy in $matchingIntents) { if (-not $processedSet.Add($policy.id)) { continue } try { - $resp = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $resp = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get } catch { Write-Host "Error fetching assignments for intent $($policy.displayName): $($_.Exception.Message)" -ForegroundColor Red @@ -327,7 +327,7 @@ function Get-IntuneUserDeviceAssignment { $assignmentsUri = Get-AppProtectionAssignmentUri -Policy $policy if (-not $assignmentsUri) { continue } try { - $resp = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get + $resp = Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get $assignmentList = foreach ($a in $resp.value) { [PSCustomObject]@{ Reason = switch ($a.target.'@odata.type') { @@ -375,12 +375,8 @@ function Get-IntuneUserDeviceAssignment { $appUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq true&`$select=id,displayName,roleScopeTagIds" $allApps = [System.Collections.Generic.List[object]]::new() try { - $appResponse = Invoke-MgGraphRequest -Uri $appUri -Method Get - if ($appResponse.value) { $allApps.AddRange([object[]]$appResponse.value) } - while ($appResponse.'@odata.nextLink') { - $appResponse = Invoke-MgGraphRequest -Uri $appResponse.'@odata.nextLink' -Method Get - if ($appResponse.value) { $allApps.AddRange([object[]]$appResponse.value) } - } + $pagedApps = @((Invoke-IACGraphRequest -Uri $appUri -Method Get).value) + if ($pagedApps.Count -gt 0) { $allApps.AddRange([object[]]$pagedApps) } } catch { Write-Host "Error fetching applications: $($_.Exception.Message)" -ForegroundColor Red @@ -392,7 +388,7 @@ function Get-IntuneUserDeviceAssignment { try { $assignmentsUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps('$($app.id)')/assignments" - $resp = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get + $resp = Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get # Single pass: capture exclusion membership, the winning include, and the intent. # We need the intent from an inclusion to know which app bucket to route into, diff --git a/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 b/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 index a28b6cd..b653b1a 100644 --- a/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 @@ -157,21 +157,15 @@ function Search-IntuneSetting { $allPolicies = [System.Collections.ArrayList]::new() $policyUri = "$($script:GraphEndpoint)/beta/deviceManagement/configurationPolicies?`$select=id,name,description,templateReference" - do { - try { - $policyResponse = Invoke-MgGraphRequest -Uri $policyUri -Method Get - if ($policyResponse.value) { - foreach ($p in $policyResponse.value) { - $null = $allPolicies.Add($p) - } - } - $policyUri = $policyResponse.'@odata.nextLink' - } - catch { - Write-Host "Error fetching policies: $($_.Exception.Message)" -ForegroundColor Red - return + try { + foreach ($policy in @((Invoke-IACGraphRequest -Uri $policyUri -Method Get).value)) { + $null = $allPolicies.Add($policy) } - } while (![string]::IsNullOrEmpty($policyUri)) + } + catch { + Write-Host "Error fetching policies: $($_.Exception.Message)" -ForegroundColor Red + return + } Write-Host "Found $($allPolicies.Count) configuration policies. Scanning settings..." -ForegroundColor Gray @@ -192,9 +186,9 @@ function Search-IntuneSetting { $settingsUri = "$($script:GraphEndpoint)/beta/deviceManagement/configurationPolicies('$($policy.id)')/settings" try { - $settingsResponse = Invoke-MgGraphRequest -Uri $settingsUri -Method Get - if ($settingsResponse.value) { - foreach ($setting in $settingsResponse.value) { + $settings = @((Invoke-IACGraphRequest -Uri $settingsUri -Method Get).value) + if ($settings.Count -gt 0) { + foreach ($setting in $settings) { $instance = $setting.settingInstance if ($null -eq $instance) { continue } diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 index 0a04210..cde049b 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 @@ -109,8 +109,8 @@ function Test-IntuneGroupMembership { # Single quotes escaped for the OData filter (F9) $escapedSimGroupName = $simGroupInput -replace "'", "''" $simGroupSelect = 'id,displayName,groupTypes,mailEnabled,securityEnabled,mail' - $simGroupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedSimGroupName'&`$select=$simGroupSelect" - $simGroupResponse = Invoke-MgGraphRequest -Uri $simGroupUri -Method Get + $simGroupUri = "$script:GraphEndpoint/beta/groups?`$filter=displayName eq '$escapedSimGroupName'&`$select=$simGroupSelect" + $simGroupResponse = Invoke-IACGraphRequest -Uri $simGroupUri -Method Get if ($simGroupResponse.value.Count -eq 0) { Write-Host "No group found with name: $simGroupInput" -ForegroundColor Red diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 index 942522a..11db464 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 @@ -131,8 +131,8 @@ function Test-IntuneGroupRemoval { else { $escapedSimGroupName = $simGroupInput -replace "'", "''" $simGroupSelect = 'id,displayName,groupTypes,mailEnabled,securityEnabled,mail' - $simGroupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedSimGroupName'&`$select=$simGroupSelect" - $simGroupResponse = Invoke-MgGraphRequest -Uri $simGroupUri -Method Get + $simGroupUri = "$script:GraphEndpoint/beta/groups?`$filter=displayName eq '$escapedSimGroupName'&`$select=$simGroupSelect" + $simGroupResponse = Invoke-IACGraphRequest -Uri $simGroupUri -Method Get if ($simGroupResponse.value.Count -eq 0) { Write-Host "No group found with name: $simGroupInput" -ForegroundColor Red diff --git a/Module/IntuneAssignmentChecker/Public/Update-IntuneSettingDefinition.ps1 b/Module/IntuneAssignmentChecker/Public/Update-IntuneSettingDefinition.ps1 index 0dc10b8..d86326a 100644 --- a/Module/IntuneAssignmentChecker/Public/Update-IntuneSettingDefinition.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Update-IntuneSettingDefinition.ps1 @@ -21,15 +21,10 @@ function Update-IntuneSettingDefinition { $allDefinitions = [System.Collections.ArrayList]::new() $uri = "$($script:GraphEndpoint)/beta/deviceManagement/configurationSettings?`$select=id,displayName,description,keywords,baseUri,offsetUri,categoryId" - $page = 0 - do { - $page++ - Write-Host "`rFetching page $page..." -NoNewline - try { - $response = Invoke-MgGraphRequest -Uri $uri -Method Get - if ($response.value) { - foreach ($def in $response.value) { - $null = $allDefinitions.Add([PSCustomObject]@{ + Write-Host "`rFetching paged definitions..." -NoNewline + try { + foreach ($def in @((Invoke-IACGraphRequest -Uri $uri -Method Get).value)) { + $null = $allDefinitions.Add([PSCustomObject]@{ id = $def.id displayName = $def.displayName description = $def.description @@ -37,15 +32,12 @@ function Update-IntuneSettingDefinition { baseUri = $def.baseUri offsetUri = $def.offsetUri }) - } - } - $uri = $response.'@odata.nextLink' } - catch { - Write-Host "`nError fetching definitions: $($_.Exception.Message)" -ForegroundColor Red - return - } - } while (![string]::IsNullOrEmpty($uri)) + } + catch { + Write-Host "`nError fetching definitions: $($_.Exception.Message)" -ForegroundColor Red + return + } Write-Host "`rFetched $($allDefinitions.Count) setting definitions." -ForegroundColor Green diff --git a/Module/IntuneAssignmentChecker/html-export.ps1 b/Module/IntuneAssignmentChecker/html-export.ps1 index 3c035a3..2137f10 100644 --- a/Module/IntuneAssignmentChecker/html-export.ps1 +++ b/Module/IntuneAssignmentChecker/html-export.ps1 @@ -719,7 +719,7 @@ function Export-HTMLReport { if ($assignmentsUri) { try { - $assignmentResponse = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get + $assignmentResponse = Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get # Pass the raw .value to Get-HtmlAssignmentInfo as it expects an array of assignment objects $assignmentInfo = Get-HtmlAssignmentInfo -Assignments $assignmentResponse.value @@ -903,7 +903,7 @@ function Export-HTMLReport { foreach ($policy in $intentPolicies) { if ($processedIds.Add($policy.id)) { try { - $assignmentsResponse = Invoke-MgGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get + $assignmentsResponse = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments" -Method Get $assignmentInfo = Get-HtmlAssignmentInfo -Assignments $assignmentsResponse.value # This expects an array $policies[$esCategory.Key] += @{ Name = if (-not [string]::IsNullOrWhiteSpace($policy.displayName)) { $policy.displayName } else { $policy.name } @@ -929,12 +929,8 @@ function Export-HTMLReport { $appUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq true&`$select=id,displayName,roleScopeTagIds" $allApps = [System.Collections.Generic.List[object]]::new() try { - $appResponse = Invoke-MgGraphRequest -Uri $appUri -Method Get - if ($appResponse.value) { $allApps.AddRange([object[]]$appResponse.value) } - while ($appResponse.'@odata.nextLink') { - $appResponse = Invoke-MgGraphRequest -Uri $appResponse.'@odata.nextLink' -Method Get - if ($appResponse.value) { $allApps.AddRange([object[]]$appResponse.value) } - } + $pagedApps = @((Invoke-IACGraphRequest -Uri $appUri -Method Get).value) + if ($pagedApps.Count -gt 0) { $allApps.AddRange([object[]]$pagedApps) } } catch { Write-Host "Error fetching applications: $($_.Exception.Message)" -ForegroundColor Red @@ -945,7 +941,7 @@ function Export-HTMLReport { $appId = $app.id $assignmentsUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps('$appId')/assignments" try { - $assignmentResponse = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get + $assignmentResponse = Invoke-IACGraphRequest -Uri $assignmentsUri -Method Get } catch { Write-Host "Error fetching assignments for app $($app.displayName): $($_.Exception.Message)" -ForegroundColor Red diff --git a/README.md b/README.md index 0c3247d..1449032 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,10 @@ For certificate, client secret, managed identity, or pre-fetched token authentic > **Note**: The automated setup script ([`Register-IntuneAssignmentCheckerApp.ps1`](./Register-IntuneAssignmentCheckerApp.ps1)) additionally grants `DeviceManagementServiceConfig.Read.All`, which covers Intune service configuration such as enrollment settings. It is not validated by `Connect-IntuneAssignmentChecker`, but granting it avoids gaps when reading enrollment-related configurations. +### Microsoft Graph API behavior + +IntuneAssignmentChecker uses the Microsoft Graph `/beta` endpoint in every supported cloud. Starting with v4.4, all Graph traffic is routed through one internal transport that follows collection paging automatically, honors throttling responses, retries transient service and network failures, and preserves Graph request identifiers in structured errors for troubleshooting. The beta endpoint can change more frequently than a generally available endpoint, so validate a new module version in a test tenant before broad automation rollout. + ## ๐Ÿ” Authentication Options ### Option 1: Certificate-Based Authentication (Recommended for automation) diff --git a/Tests/Unit/CategoryScan.Tests.ps1 b/Tests/Unit/CategoryScan.Tests.ps1 index ae4f477..41c69d3 100644 --- a/Tests/Unit/CategoryScan.Tests.ps1 +++ b/Tests/Unit/CategoryScan.Tests.ps1 @@ -26,7 +26,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -100,7 +100,7 @@ Describe 'Invoke-IntuneCategoryScan' { Mock Get-IntuneEntities { @() } Mock Get-IntuneAssignments { @() } Mock Add-IntentTemplateFamilyInfo {} - Mock Invoke-MgGraphRequest { @{ value = @() } } + Mock Invoke-IACGraphRequest { @{ value = @() } } } Context 'entity caching' { @@ -154,7 +154,7 @@ Describe 'Invoke-IntuneCategoryScan' { default { return @() } } } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ value = @(@{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } }) } } @@ -169,7 +169,7 @@ Describe 'Invoke-IntuneCategoryScan' { $script:intentContexts[0].Entity.id | Should -Be 'intent-macos-fv' $script:intentContexts[0].Assignments[0].Reason | Should -BeExactly 'All Devices' @($script:intentContexts[0].RawAssignments).Count | Should -Be 1 - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*deviceManagement/intents/intent-macos-fv/assignments*' } + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*deviceManagement/intents/intent-macos-fv/assignments*' } } } @@ -287,7 +287,7 @@ Describe 'Invoke-IntuneCategoryScan' { Context 'mobile apps' { It 'keeps every assigned app regardless of featured metadata and passes RawAssignments to the callback' { - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri.Contains('mobileApps?$filter=isAssigned')) { return @{ value = @( @{ id = 'app-1'; displayName = 'Real App'; isFeatured = $false; isBuiltIn = $false } @@ -330,7 +330,7 @@ Describe 'Invoke-IntuneCategoryScan' { } return @() } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ value = @( @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'grp-1' } } @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = 'grp-other' } } diff --git a/Tests/Unit/CompareGroupAssignment.Tests.ps1 b/Tests/Unit/CompareGroupAssignment.Tests.ps1 index aa45c0e..ab6cb29 100644 --- a/Tests/Unit/CompareGroupAssignment.Tests.ps1 +++ b/Tests/Unit/CompareGroupAssignment.Tests.ps1 @@ -27,7 +27,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -114,17 +114,17 @@ Describe 'Compare-IntuneGroupAssignment' { @($records | Where-Object { $GroupIds -contains $_.GroupId }) } - Mock Invoke-MgGraphRequest { - if ($Uri -like "*/v1.0/groups*displayName eq*") { + Mock Invoke-IACGraphRequest { + if ($Uri -like "*/beta/groups*displayName eq*") { if ($Uri -like "*O''Brien Team*") { return @{ value = @(@{ id = $script:groupB; displayName = "O'Brien Team" }) } } return @{ value = @() } } - if ($Uri -like "*/v1.0/groups/$($script:groupA)*") { + if ($Uri -like "*/beta/groups/$($script:groupA)*") { return @{ id = $script:groupA; displayName = 'Group A'; groupTypes = @(); mailEnabled = $false; securityEnabled = $true } } - if ($Uri -like "*/v1.0/groups/$($script:groupB)*") { + if ($Uri -like "*/beta/groups/$($script:groupB)*") { return @{ id = $script:groupB; displayName = 'Group B'; groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false; mail = 'groupb@contoso.com' } } if ($Uri -like '*mobileApps*isAssigned eq true*') { @@ -182,13 +182,13 @@ Describe 'Compare-IntuneGroupAssignment' { It 'escapes single quotes in group name lookups' { Compare-IntuneGroupAssignment -CompareGroupNames "O'Brien Team, $($script:groupA)" -ExportToCSV -ExportPath $script:csvPath - Should -Invoke Invoke-MgGraphRequest -ParameterFilter { $Uri -like "*displayName eq 'O''Brien Team'*" } + Should -Invoke Invoke-IACGraphRequest -ParameterFilter { $Uri -like "*displayName eq 'O''Brien Team'*" } } It 'skips a GUID lookup response that omits the group Object ID' { - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ displayName = 'Incomplete Group'; groupTypes = @('Unified'); mailEnabled = $true } - } -ParameterFilter { $Uri -like "*/v1.0/groups/$($script:groupA)*" } + } -ParameterFilter { $Uri -like "*/beta/groups/$($script:groupA)*" } Compare-IntuneGroupAssignment -CompareGroupNames "$($script:groupA), $($script:groupB)" -ExportToCSV -ExportPath $script:csvPath @@ -293,7 +293,7 @@ Describe 'Compare-IntuneGroupAssignment' { Should -Invoke Get-IntuneEntities -Exactly 1 -ParameterFilter { $EntityType -eq 'deviceConfigurations' } Should -Invoke Get-IntuneEntities -Exactly 1 -ParameterFilter { $EntityType -eq 'deviceManagement/intents' } Should -Invoke Get-IntuneEntities -Exactly 1 -ParameterFilter { $EntityType -eq 'deviceShellScripts' } - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*mobileApps*isAssigned eq true*' } + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*mobileApps*isAssigned eq true*' } } } diff --git a/Tests/Unit/Connection.Tests.ps1 b/Tests/Unit/Connection.Tests.ps1 index e83b9db..f27d833 100644 --- a/Tests/Unit/Connection.Tests.ps1 +++ b/Tests/Unit/Connection.Tests.ps1 @@ -19,7 +19,7 @@ BeforeAll { } function Get-MgContext { } - function Invoke-MgGraphRequest { } + function Invoke-IACGraphRequest { } function Set-Environment { } function Get-ScopeTagLookup { } function Get-AssignmentFilterLookup { } @@ -61,7 +61,7 @@ Describe 'Connect-IntuneAssignmentChecker interactive authentication' { } } Mock Connect-MgGraph - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ value = @([PSCustomObject]@{ displayName = 'Contoso' }) } } Mock Get-ScopeTagLookup { @{} } diff --git a/Tests/Unit/DeviceAssignment.Tests.ps1 b/Tests/Unit/DeviceAssignment.Tests.ps1 index 155010e..d88a136 100644 --- a/Tests/Unit/DeviceAssignment.Tests.ps1 +++ b/Tests/Unit/DeviceAssignment.Tests.ps1 @@ -37,7 +37,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -78,7 +78,7 @@ Describe 'Get-IntuneDeviceAssignment' { } Mock Get-GroupMemberships { @([PSCustomObject]@{ id = $script:memberGroupId; displayName = 'Group One' }) } Mock Export-ResultsIfRequested { $script:capturedExport = @($ExportData) } - Mock Invoke-MgGraphRequest { @{ value = @() } } + Mock Invoke-IACGraphRequest { @{ value = @() } } } It 'exports the Device row first' { @@ -165,7 +165,7 @@ Describe 'Get-IntuneDeviceAssignment' { Context 'applications' { BeforeEach { - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*mobileApps?*isAssigned*') { return @{ value = @( @{ id = 'app-f14'; displayName = 'F14 App'; isFeatured = $false; isBuiltIn = $false; '@odata.type' = '#microsoft.graph.win32LobApp' } @@ -216,7 +216,7 @@ Describe 'Get-IntuneDeviceAssignment' { It 'never fetches assignments for apps of another platform' { Get-IntuneDeviceAssignment -DeviceNames 'PC-1' - Should -Invoke Invoke-MgGraphRequest -Times 0 -ParameterFilter { $Uri -like "*mobileApps('app-ios')*" } + Should -Invoke Invoke-IACGraphRequest -Times 0 -ParameterFilter { $Uri -like "*mobileApps('app-ios')*" } @($script:capturedExport | Where-Object { $_.Item -like '*app-ios*' }).Count | Should -Be 0 } } @@ -232,7 +232,7 @@ Describe 'Get-IntuneDeviceAssignment' { } @() } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like "*windowsManagedAppProtections('mam-member')/assignments*") { return @{ value = @( @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } @@ -253,7 +253,7 @@ Describe 'Get-IntuneDeviceAssignment' { $rows[0].Item | Should -BeExactly 'Member MAM (ID: mam-member)' $rows[0].AssignmentReason | Should -BeExactly 'Group Assignment - Group One' # Platform-incompatible policies never trigger an assignment fetch - Should -Invoke Invoke-MgGraphRequest -Times 0 -ParameterFilter { $Uri -like '*androidManagedAppProtections*' } + Should -Invoke Invoke-IACGraphRequest -Times 0 -ParameterFilter { $Uri -like '*androidManagedAppProtections*' } } It 'surfaces Endpoint Security policies from both configurationPolicies and intents' { @@ -272,7 +272,7 @@ Describe 'Get-IntuneDeviceAssignment' { if ($EntityId -eq 'av-1') { return @([PSCustomObject]@{ Reason = 'Group Assignment'; GroupId = $script:memberGroupId; FilterId = $null; FilterType = $null }) } @() } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*intents/av-int-1/assignments*') { return @{ value = @(@{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } }) } } @@ -322,7 +322,7 @@ Describe 'Get-IntuneDeviceAssignment' { Should -Invoke Get-IntuneEntities -Exactly -Times 1 -ParameterFilter { $EntityType -eq 'configurationPolicies' } Should -Invoke Get-IntuneEntities -Exactly -Times 1 -ParameterFilter { $EntityType -eq 'deviceManagement/intents' } - Should -Invoke Invoke-MgGraphRequest -Exactly -Times 1 -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } + Should -Invoke Invoke-IACGraphRequest -Exactly -Times 1 -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } } } @@ -337,7 +337,7 @@ Describe 'Get-IntuneDeviceAssignment' { } } Mock Get-IntuneAssignments { @([PSCustomObject]@{ Reason = 'All Devices'; GroupId = $null; FilterId = $null; FilterType = $null }) } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*mobileApps?*isAssigned*') { return @{ value = @(@{ id = 'app-1'; displayName = 'App'; isFeatured = $false; isBuiltIn = $false; '@odata.type' = '#microsoft.graph.win32LobApp' }) } } diff --git a/Tests/Unit/GraphMembership.Tests.ps1 b/Tests/Unit/GraphMembership.Tests.ps1 index 47fa3e0..86e85b8 100644 --- a/Tests/Unit/GraphMembership.Tests.ps1 +++ b/Tests/Unit/GraphMembership.Tests.ps1 @@ -4,7 +4,7 @@ BeforeAll { $modulePrivate = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Private' - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) $null = $Uri $null = $Method @@ -20,23 +20,18 @@ Describe 'Get-TransitiveGroupMembership' { BeforeEach { $script:requestedUris = [System.Collections.Generic.List[string]]::new() - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { $script:requestedUris.Add($Uri) - if ($Uri -eq 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName') { + if ($Uri -eq 'https://graph.test/beta/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName') { return @{ value = @( [PSCustomObject]@{ id = 'parent-1'; displayName = 'Parent One' } + [PSCustomObject]@{ id = 'parent-2'; displayName = 'Parent Two' } ) - '@odata.nextLink' = 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$skiptoken=next' } } - - return @{ - value = @( - [PSCustomObject]@{ id = 'parent-2'; displayName = 'Parent Two' } - ) - } + throw "Unexpected URI: $Uri" } } @@ -47,9 +42,8 @@ Describe 'Get-TransitiveGroupMembership' { $result[0].id | Should -BeExactly 'parent-1' $result[1].id | Should -BeExactly 'parent-2' $script:requestedUris | Should -Be @( - 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName' - 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$skiptoken=next' + 'https://graph.test/beta/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName' ) - Should -Invoke Invoke-MgGraphRequest -Exactly 2 -ParameterFilter { $Method -eq 'Get' } + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Method -eq 'Get' } } } diff --git a/Tests/Unit/GraphTransport.Tests.ps1 b/Tests/Unit/GraphTransport.Tests.ps1 new file mode 100644 index 0000000..407ea1c --- /dev/null +++ b/Tests/Unit/GraphTransport.Tests.ps1 @@ -0,0 +1,264 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + + function Invoke-MgGraphRequest { + param([string]$Uri, [string]$Method, [object]$Body, [string]$ErrorAction) + $null = $Uri + $null = $Method + $null = $Body + $null = $ErrorAction + } + + . (Join-Path $moduleRoot 'Private/Invoke-IACGraphRequest.ps1') +} + +Describe 'Invoke-IACGraphRequest' { + BeforeEach { + $script:GraphEndpoint = 'https://graph.test' + $script:requestCount = 0 + Mock Start-Sleep + } + + It 'normalizes relative and legacy-version paths to the active beta endpoint' { + Mock Invoke-MgGraphRequest { @{ value = @() } } + + Invoke-IACGraphRequest -Uri '/v1.0/groups' | Out-Null + Invoke-IACGraphRequest -Uri 'deviceManagement/configurationPolicies' | Out-Null + + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/groups' -and $Method -eq 'GET' + } + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/deviceManagement/configurationPolicies' -and $Method -eq 'GET' + } + } + + It 'preserves realistic OData filters, selects, and keyed resource paths' { + Mock Invoke-MgGraphRequest { @{ value = @() } } + $realisticUri = "https://graph.test/beta/groups?`$filter=displayName eq 'My Group'&`$select=id,displayName" + $keyedUri = "https://graph.test/beta/deviceManagement/deviceShellScripts('script-id')/groupAssignments" + + Invoke-IACGraphRequest -Uri $realisticUri | Out-Null + Invoke-IACGraphRequest -Uri $keyedUri | Out-Null + + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq $realisticUri } + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq $keyedUri } + } + + It 'follows beta nextLink pages when AllPages is requested' { + Mock Invoke-MgGraphRequest { + if ($Uri -like '*skiptoken=next') { + return @{ value = @([PSCustomObject]@{ id = 'two' }) } + } + return @{ + value = @([PSCustomObject]@{ id = 'one' }) + '@odata.nextLink' = 'https://graph.test/beta/groups?$skiptoken=next' + } + } + + $result = Invoke-IACGraphRequest -Uri '/groups' -AllPages + + $result -is [array] | Should -BeTrue + $result.id | Should -Be @('one', 'two') + Should -Invoke Invoke-MgGraphRequest -Exactly 2 + } + + It 'automatically combines pages for existing response.value callers' { + Mock Invoke-MgGraphRequest { + if ($Uri -like '*skiptoken=next') { + return @{ value = @([PSCustomObject]@{ id = 'two' }) } + } + return @{ + '@odata.context' = 'context' + value = @([PSCustomObject]@{ id = 'one' }) + '@odata.nextLink' = 'https://graph.test/beta/groups?$skiptoken=next' + } + } + + $response = Invoke-IACGraphRequest -Uri '/groups' + + $response.'@odata.context' | Should -BeExactly 'context' + $response.value.id | Should -Be @('one', 'two') + $response.PSObject.Properties.Name | Should -Not -Contain '@odata.nextLink' + Should -Invoke Invoke-MgGraphRequest -Exactly 2 + } + + It 'retries transient responses and succeeds' { + Mock Invoke-MgGraphRequest { + $script:requestCount++ + if ($script:requestCount -lt 3) { throw 'HTTP 429 Too Many Requests' } + @{ value = @([PSCustomObject]@{ id = 'ok' }) } + } + + $result = Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 3 + + $result.value[0].id | Should -BeExactly 'ok' + Should -Invoke Invoke-MgGraphRequest -Exactly 3 + Should -Invoke Start-Sleep -Exactly 2 + } + + It 'retries typed connection failures without guessing status codes from unrelated numbers' { + Mock Invoke-MgGraphRequest { + $script:requestCount++ + if ($script:requestCount -eq 1) { throw [System.TimeoutException]::new('socket timeout') } + @{ value = @() } + } + + Invoke-IACGraphRequest -Uri '/groups?$top=500' | Out-Null + + Should -Invoke Invoke-MgGraphRequest -Exactly 2 + Should -Invoke Start-Sleep -Exactly 1 + } + + It 'does not retry permanent 4xx responses' { + Mock Invoke-MgGraphRequest { throw 'HTTP 403 Forbidden' } + + { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 3 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Exactly 1 + Should -Invoke Start-Sleep -Exactly 0 + } + + It 'does not retry a status-bearing HttpRequestException for a permanent 4xx' { + Mock Invoke-MgGraphRequest { + throw [System.Net.Http.HttpRequestException]::new( + 'Response status code does not indicate success: 403 (Forbidden).', + $null, + [System.Net.HttpStatusCode]::Forbidden + ) + } + + { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 3 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Exactly 1 + Should -Invoke Start-Sleep -Exactly 0 + } + + It 'throws after the configured transient retry count is exhausted' { + Mock Invoke-MgGraphRequest { throw 'HTTP 503 Service Unavailable' } + + { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 2 } | Should -Throw + + Should -Invoke Invoke-MgGraphRequest -Exactly 3 + Should -Invoke Start-Sleep -Exactly 2 + } + + It 'honors Retry-After without shortening the server delay' { + Mock Invoke-MgGraphRequest { + $script:requestCount++ + if ($script:requestCount -eq 1) { + $exception = [System.Exception]::new('HTTP 429 Too Many Requests') + $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([PSCustomObject]@{ + Headers = [PSCustomObject]@{ 'Retry-After' = '75' } + }) + throw $exception + } + @{ value = @() } + } + + Invoke-IACGraphRequest -Uri '/groups' | Out-Null + + Should -Invoke Start-Sleep -Exactly 1 -ParameterFilter { $Seconds -eq 75 } + } + + It 'forwards a POST body only on the first page' { + Mock Invoke-MgGraphRequest { + if ($Uri -like '*skiptoken=next') { return @{ value = @('two') } } + return @{ value = @('one'); '@odata.nextLink' = 'https://graph.test/beta/reports/items?$skiptoken=next' } + } + $body = @{ select = @('id') } + + $result = Invoke-IACGraphRequest -Uri '/reports/items' -Method POST -Body $body -AllPages + + $result -is [array] | Should -BeTrue + $result | Should -Be @('one', 'two') + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Method -eq 'POST' -and $Body -eq $body } + Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Method -eq 'GET' } + } + + It 'preserves structured Graph error details in a terminating error' { + Mock Invoke-MgGraphRequest { + $record = [System.Management.Automation.ErrorRecord]::new( + [System.Exception]::new('HTTP 403 Forbidden'), + 'GraphFailure', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $Uri + ) + $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('{"error":{"code":"Authorization_RequestDenied","message":"Denied","innerError":{"request-id":"request-1","client-request-id":"client-1"}}}') + throw $record + } + + $caught = $null + try { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 0 } + catch { $caught = $_ } + + $caught.FullyQualifiedErrorId | Should -Match '^IntuneAssignmentChecker.GraphRequestFailed' + $caught.Exception.Data['StatusCode'] | Should -Be 403 + $caught.Exception.Data['GraphErrorCode'] | Should -BeExactly 'Authorization_RequestDenied' + $caught.Exception.Data['RequestId'] | Should -BeExactly 'request-1' + $caught.Exception.Data['ClientRequestId'] | Should -BeExactly 'client-1' + } + + It 'rejects absolute URLs outside the active cloud endpoint' { + Mock Invoke-MgGraphRequest + { Invoke-IACGraphRequest -Uri 'https://graph.microsoft.com/beta/groups' } | + Should -Throw '*does not match the active cloud endpoint*' + Should -Invoke Invoke-MgGraphRequest -Exactly 0 + } + + It 'rejects a nextLink outside beta with a structured security error' { + Mock Invoke-MgGraphRequest { + @{ value = @(); '@odata.nextLink' = 'https://graph.test/v1.0/groups?$skiptoken=next' } + } + + $caught = $null + try { Invoke-IACGraphRequest -Uri '/groups' -AllPages } + catch { $caught = $_ } + + $caught.FullyQualifiedErrorId | Should -Match '^IntuneAssignmentChecker.InvalidGraphNextLink' + $caught.Exception.Data['RequestUri'] | Should -BeExactly 'https://graph.test/v1.0/groups?$skiptoken=next' + } + + It 'stops repeated nextLink loops and enforces a page cap' { + Mock Invoke-MgGraphRequest { + @{ value = @(); '@odata.nextLink' = 'https://graph.test/beta/groups' } + } + + { Invoke-IACGraphRequest -Uri '/groups' -AllPages } | Should -Throw '*repeated nextLink*' + + Mock Invoke-MgGraphRequest { + @{ value = @(); '@odata.nextLink' = 'https://graph.test/beta/groups?page=2' } + } + { Invoke-IACGraphRequest -Uri '/groups' -AllPages -MaxPageCount 1 } | Should -Throw '*maximum of 1 pages*' + } + + It 'returns an empty collection for an empty paged response' { + Mock Invoke-MgGraphRequest { @{ value = @() } } + + $result = Invoke-IACGraphRequest -Uri '/groups' -AllPages + + $result -is [array] | Should -BeTrue + $result.Count | Should -Be 0 + } + + It 'requires an active Graph endpoint' { + Mock Invoke-MgGraphRequest + $script:GraphEndpoint = $null + + { Invoke-IACGraphRequest -Uri '/groups' } | Should -Throw '*Connect-IntuneAssignmentChecker*' + Should -Invoke Invoke-MgGraphRequest -Exactly 0 + } + + It 'keeps direct Graph SDK calls and v1.0 literals out of module code' { + $files = Get-ChildItem -Path $moduleRoot -Recurse -File -Include *.ps1, *.psm1 + $sdkCalls = @($files | Where-Object { $_.Name -ne 'Invoke-IACGraphRequest.ps1' } | + Select-String -Pattern 'Invoke-MgGraphRequest') + $legacyVersions = @($files | Select-String -Pattern '/v1\.0/') + + $sdkCalls.Count | Should -Be 0 + $legacyVersions.Count | Should -Be 0 + } +} diff --git a/Tests/Unit/GroupAssignment.Tests.ps1 b/Tests/Unit/GroupAssignment.Tests.ps1 index 507925e..51cc2a7 100644 --- a/Tests/Unit/GroupAssignment.Tests.ps1 +++ b/Tests/Unit/GroupAssignment.Tests.ps1 @@ -36,7 +36,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -155,7 +155,7 @@ Describe 'Get-IntuneGroupAssignment' { } } Mock Export-ResultsIfRequested { $script:capturedExport = @($ExportData) } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*mobileApps*isAssigned eq true*') { return @{ value = @( @@ -163,12 +163,6 @@ Describe 'Get-IntuneGroupAssignment' { [PSCustomObject]@{ id = 'app-exc'; displayName = 'Excluded Only App'; isFeatured = $false; isBuiltIn = $false } [PSCustomObject]@{ id = 'app-both'; displayName = 'Included And Excluded App'; isFeatured = $false; isBuiltIn = $false } [PSCustomObject]@{ id = 'app-exc-uninstall'; displayName = 'Excluded Uninstall App'; isFeatured = $false; isBuiltIn = $false } - ) - '@odata.nextLink' = 'https://graph.test/next-mobile-app-page' - } - } - if ($Uri -eq 'https://graph.test/next-mobile-app-page') { - return @{ value = @( [PSCustomObject]@{ id = 'app-featured-required'; displayName = 'Featured Required App'; isFeatured = $true; roleScopeTagIds = @('0') } [PSCustomObject]@{ id = 'app-featured-available'; displayName = 'Featured Available App'; isFeatured = $true; roleScopeTagIds = @('0') } ) @@ -261,14 +255,14 @@ Describe 'Get-IntuneGroupAssignment' { } It 'recognizes a Microsoft 365 group by display name without filtering it out' { - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ value = @([PSCustomObject]@{ id = 'm365-by-name'; displayName = 'Messaging Team'; groupTypes = @('Unified') mailEnabled = $true; securityEnabled = $false; mail = 'messaging@contoso.com' }) } - } -ParameterFilter { $Uri -like '*/v1.0/groups?*displayName eq*' } + } -ParameterFilter { $Uri -like '*/beta/groups?*displayName eq*' } Get-IntuneGroupAssignment -GroupNames 'Messaging Team' -IncludeNestedGroups @@ -276,8 +270,8 @@ Describe 'Get-IntuneGroupAssignment' { $groupRow.Item | Should -BeExactly 'Messaging Team (ID: m365-by-name)' $groupRow.GroupType | Should -BeExactly 'Microsoft 365' $groupRow.GroupMail | Should -BeExactly 'messaging@contoso.com' - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { - $Uri -like '*/v1.0/groups?*' -and + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -like '*/beta/groups?*' -and $Uri -match '\$select=id,displayName,groupTypes,mailEnabled,securityEnabled,mail' } } @@ -348,7 +342,7 @@ Describe 'Get-IntuneGroupAssignment' { $appRow = $script:capturedExport | Where-Object { $_.Item -eq 'Included App (ID: app-inc)' } $appRow.ScopeTags | Should -BeExactly 'Default, Finance' - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*deviceAppManagement/mobileApps?*' -and $Uri -match '\$select=[^&]*roleScopeTagIds' } @@ -364,7 +358,7 @@ Describe 'Get-IntuneGroupAssignment' { $availableRow.Category | Should -BeExactly 'Available Apps' $requiredRow.AssignmentReason | Should -BeExactly 'Direct Assignment (Filter: Unknown Filter (shared-filter) [Include])' $availableRow.AssignmentReason | Should -BeExactly 'Direct Assignment (Filter: Unknown Filter (shared-filter) [Include])' - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq 'https://graph.test/next-mobile-app-page' } + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*mobileApps*isAssigned eq true*' } } It 'prefers the inclusion intent when the group is both included and excluded' { diff --git a/Tests/Unit/GroupInfo.Tests.ps1 b/Tests/Unit/GroupInfo.Tests.ps1 index 0b283b9..734dbb9 100644 --- a/Tests/Unit/GroupInfo.Tests.ps1 +++ b/Tests/Unit/GroupInfo.Tests.ps1 @@ -4,7 +4,7 @@ BeforeAll { $modulePrivate = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Private' - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) $null = $Uri $null = $Method @@ -65,7 +65,7 @@ Describe 'Get-GroupInfo' { BeforeEach { $script:GroupInfoCache = $null Mock Write-Warning {} - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { [PSCustomObject]@{ id = 'm365-1'; displayName = 'Messaging Team'; groupTypes = @('Unified') mailEnabled = $true; securityEnabled = $false; mail = 'messaging@contoso.com' @@ -80,14 +80,14 @@ Describe 'Get-GroupInfo' { $first.GroupType | Should -BeExactly 'Microsoft 365' $first.Mail | Should -BeExactly 'messaging@contoso.com' $second | Should -Be $first - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Method -eq 'Get' -and - $Uri -eq 'https://graph.test/v1.0/groups/m365-1?$select=id,displayName,groupTypes,mailEnabled,securityEnabled,mail' + $Uri -eq 'https://graph.test/beta/groups/m365-1?$select=id,displayName,groupTypes,mailEnabled,securityEnabled,mail' } } It 'returns a complete unknown result when Graph lookup fails' { - Mock Invoke-MgGraphRequest { throw 'service unavailable' } + Mock Invoke-IACGraphRequest { throw 'service unavailable' } $result = Get-GroupInfo -GroupId 'missing-group' @@ -101,7 +101,7 @@ Describe 'Get-GroupInfo' { } It 'does not warn when Graph reports a message-only 404 for a stale group assignment' { - Mock Invoke-MgGraphRequest { throw '404 Request_ResourceNotFound: Group was not found' } + Mock Invoke-IACGraphRequest { throw '404 Request_ResourceNotFound: Group was not found' } $result = Get-GroupInfo -GroupId 'deleted-group' @@ -111,7 +111,7 @@ Describe 'Get-GroupInfo' { } It 'rejects and diagnoses a successful response that omits the group Object ID' { - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { [PSCustomObject]@{ displayName = 'Incomplete'; groupTypes = @('Unified'); mailEnabled = $true } } diff --git a/Tests/Unit/HtmlReportCsv.Tests.ps1 b/Tests/Unit/HtmlReportCsv.Tests.ps1 index 8557598..d447589 100644 --- a/Tests/Unit/HtmlReportCsv.Tests.ps1 +++ b/Tests/Unit/HtmlReportCsv.Tests.ps1 @@ -17,7 +17,7 @@ BeforeAll { function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId) @() } function Get-AppProtectionAssignmentUri { param($Policy) $null } function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } function Get-GroupInfo { param([string]$GroupId) @{ DisplayName = "Group $GroupId"; Success = $true } } function Connect-IntuneAssignmentChecker {} function Get-MgContext { @{ Account = 'test@contoso.com' } } @@ -52,7 +52,7 @@ Describe 'HTML report CSV companion' { } Mock Get-AppProtectionAssignmentUri { $null } Mock Add-IntentTemplateFamilyInfo {} - Mock Invoke-MgGraphRequest { @{ value = @() } } + Mock Invoke-IACGraphRequest { @{ value = @() } } } It 'exports the requested stable CSV schema with the same report data' { @@ -230,7 +230,7 @@ Describe 'HTML report CSV companion' { It 'retains mobile-app type metadata for platform classification in report rows' { Mock Get-IntuneEntities { @() } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*deviceAppManagement/mobileApps?*isAssigned*') { return @{ value = @([PSCustomObject]@{ id = 'app-ios' @@ -257,7 +257,7 @@ Describe 'HTML report CSV companion' { $row | Should -HaveCount 1 $row[0].Category | Should -BeExactly 'Required Applications' $row[0].Platform | Should -BeExactly 'iOS/iPadOS' - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*deviceAppManagement/mobileApps?*' -and $Uri -like '*$select=id,displayName,roleScopeTagIds*' } } diff --git a/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 b/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 index e607847..47c7164 100644 --- a/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 +++ b/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 @@ -9,7 +9,7 @@ BeforeAll { $script:GraphEndpoint = 'https://graph.test' - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } @@ -62,15 +62,13 @@ Describe 'Test-ImportedAdministrativeTemplate' { Describe 'Imported Administrative Template assignments' { BeforeEach { Mock Write-Warning {} - Mock Invoke-MgGraphRequest { @{ value = @() } } + Mock Invoke-IACGraphRequest { @{ value = @() } } } It 'uses the documented resource-path URI and follows assignment pagination' { $script:expectedGroupId = '11111111-1111-1111-1111-111111111111' $firstPage = 'https://graph.test/beta/deviceManagement/groupPolicyConfigurations/imported-1/assignments' - $nextPage = 'https://graph.test/imported-assignments-page-2' - - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -eq $firstPage) { return @{ value = @( @@ -80,13 +78,6 @@ Describe 'Imported Administrative Template assignments' { groupId = $script:expectedGroupId } } - ) - '@odata.nextLink' = $nextPage - } - } - if ($Uri -eq $nextPage) { - return @{ - value = @( [PSCustomObject]@{ target = [PSCustomObject]@{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget' @@ -103,13 +94,12 @@ Describe 'Imported Administrative Template assignments' { $assignments.Reason | Should -Be @('Group Assignment', 'Group Exclusion') $assignments.GroupId | Should -Be @($script:expectedGroupId, $script:expectedGroupId) - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq $firstPage -and $Method -eq 'Get' } - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq $nextPage -and $Method -eq 'Get' } + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -eq $firstPage -and $Method -eq 'Get' } } It 'preserves group filtering semantics for an imported template' { $wantedGroup = '11111111-1111-1111-1111-111111111111' - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { @{ value = @( [PSCustomObject]@{ target = [PSCustomObject]@{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $wantedGroup } } diff --git a/Tests/Unit/MobileAppScopeTags.Tests.ps1 b/Tests/Unit/MobileAppScopeTags.Tests.ps1 index e20f3ca..81f2b31 100644 --- a/Tests/Unit/MobileAppScopeTags.Tests.ps1 +++ b/Tests/Unit/MobileAppScopeTags.Tests.ps1 @@ -14,7 +14,7 @@ BeforeAll { function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId) @() } function Get-AppProtectionAssignmentUri { param($Policy) $null } function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } function Filter-ByScopeTag { param($Items) $Items } function Export-ResultsIfRequested { param( @@ -39,7 +39,7 @@ Describe 'Mobile application scope tags' { Mock Get-IntuneEntities { @() } Mock Get-IntuneAssignments { @() } Mock Add-IntentTemplateFamilyInfo - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*deviceAppManagement/mobileApps?*isAssigned eq false*') { return @{ value = @( @@ -79,7 +79,7 @@ Describe 'Mobile application scope tags' { $appRow = $script:capturedExport | Where-Object { $_.Item -eq 'Unassigned App (ID: unassigned-app)' } $appRow.ScopeTags | Should -BeExactly 'Default, Finance' - Should -Invoke Invoke-MgGraphRequest -Exactly 1 -ParameterFilter { + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { $Uri -like '*deviceAppManagement/mobileApps?*' -and $Uri -match '\$select=[^&]*roleScopeTagIds' } diff --git a/Tests/Unit/TestGroupMembership.Tests.ps1 b/Tests/Unit/TestGroupMembership.Tests.ps1 index f3070d3..b7fb1f9 100644 --- a/Tests/Unit/TestGroupMembership.Tests.ps1 +++ b/Tests/Unit/TestGroupMembership.Tests.ps1 @@ -54,7 +54,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -146,8 +146,8 @@ Describe 'Test-IntuneGroupMembership' { default { @() } } } - Mock Invoke-MgGraphRequest { - if ($Uri -like '*v1.0/groups?*') { + Mock Invoke-IACGraphRequest { + if ($Uri -like '*beta/groups?*') { return @{ value = @([PSCustomObject]@{ id = 'g-target'; displayName = 'Target Group' }) } } if ($Uri -like '*mobileApps?*isAssigned*') { @@ -288,7 +288,7 @@ Describe 'Test-IntuneGroupMembership' { Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'configurationPolicies' } Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'deviceManagement/intents' } Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'deviceConfigurations' } - Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } + Should -Invoke Invoke-IACGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } } It 'emits the 19-step progress lines including Imported Administrative Templates' { @@ -307,7 +307,7 @@ Describe 'Test-IntuneGroupMembership' { It 'escapes single quotes in the group name OData filter (F9)' { Test-IntuneGroupMembership -UserPrincipalNames 'user1@contoso.com' -SimulateTargetGroup "O'Brien Team" - Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like "*displayName eq 'O''Brien Team'*" } + Should -Invoke Invoke-IACGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like "*displayName eq 'O''Brien Team'*" } } It 'emits export categories in the legacy CSV order' { diff --git a/Tests/Unit/TestGroupRemoval.Tests.ps1 b/Tests/Unit/TestGroupRemoval.Tests.ps1 index f28f7cc..b84966c 100644 --- a/Tests/Unit/TestGroupRemoval.Tests.ps1 +++ b/Tests/Unit/TestGroupRemoval.Tests.ps1 @@ -68,7 +68,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -170,10 +170,10 @@ Describe 'Test-IntuneGroupRemoval' { default { @() } } } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { $script:requestedUris.Add([string]$Uri) switch -Wildcard ($Uri) { - '*/v1.0/groups[?]*' { return @{ value = @([PSCustomObject]@{ id = 'g-target'; displayName = 'Target Group' }) } } + '*/beta/groups[?]*' { return @{ value = @([PSCustomObject]@{ id = 'g-target'; displayName = 'Target Group' }) } } '*mobileApps[?]*' { return @{ value = @( @@ -316,7 +316,7 @@ Describe 'Test-IntuneGroupRemoval' { Context 'input handling' { It 'escapes single quotes in the group name OData filter' { Test-IntuneGroupRemoval -UserPrincipalNames 'user1@contoso.com' -GroupNames "O'Brien Team" | Out-Null - $groupLookup = @($script:requestedUris | Where-Object { $_ -like '*/v1.0/groups*' }) + $groupLookup = @($script:requestedUris | Where-Object { $_ -like '*/beta/groups*' }) $groupLookup[0] | Should -Match ([regex]::Escape("displayName eq 'O''Brien Team'")) } diff --git a/Tests/Unit/UserAssignment.Tests.ps1 b/Tests/Unit/UserAssignment.Tests.ps1 index be36828..5622337 100644 --- a/Tests/Unit/UserAssignment.Tests.ps1 +++ b/Tests/Unit/UserAssignment.Tests.ps1 @@ -43,7 +43,7 @@ BeforeAll { function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } - function Invoke-MgGraphRequest { + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } @@ -130,7 +130,7 @@ Describe 'Get-IntuneUserAssignment' { default { @() } } } - Mock Invoke-MgGraphRequest { + Mock Invoke-IACGraphRequest { if ($Uri -like '*mobileApps?*isAssigned*') { return @{ value = @( [PSCustomObject]@{ id = 'app-req-inc'; displayName = 'Required Included App'; isFeatured = $false; isBuiltIn = $false } @@ -248,7 +248,7 @@ Describe 'Get-IntuneUserAssignment' { Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'configurationPolicies' } Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'deviceConfigurations' } Should -Invoke Get-IntuneEntities -Times 1 -Exactly -ParameterFilter { $EntityType -eq 'deviceManagement/intents' } - Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } + Should -Invoke Invoke-IACGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } } It 'still resolves assignments per user when the entity cache is shared (second UPN)' { From 8be201630aade2b8c3cd2149e6d0e095dd7931ea Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:00:49 +0200 Subject: [PATCH 2/8] Add canonical assignment record output (#137) --- .../IntuneAssignmentChecker.Format.ps1xml | 31 ++++ .../IntuneAssignmentChecker.psd1 | 6 +- .../Private/ConvertTo-IACAssignmentRecord.ps1 | 70 +++++++++ .../ConvertTo-IACNormalizedAssignment.ps1 | 61 ++++++++ .../Private/Get-IntuneAssignments.ps1 | 67 +-------- .../Private/Invoke-IntuneCategoryScan.ps1 | 77 ++++------ .../Private/New-IACAssignmentRecord.ps1 | 69 +++++++++ .../Private/Select-IACAssignmentRecord.ps1 | 48 +++++++ .../Public/Get-IntuneAllDevicesAssignment.ps1 | 14 +- .../Public/Get-IntuneAllPolicies.ps1 | 13 +- .../Public/Get-IntuneAllUsersAssignment.ps1 | 14 +- .../Public/Get-IntuneDeviceAssignment.ps1 | 19 ++- .../Public/Get-IntuneGroupAssignment.ps1 | 19 ++- .../Public/Get-IntuneUnassignedPolicy.ps1 | 46 +++++- .../Public/Get-IntuneUserAssignment.ps1 | 19 ++- .../Public/Search-IntunePolicy.ps1 | 14 +- README.md | 26 ++++ Tests/Unit/AssignmentRecord.Tests.ps1 | 133 ++++++++++++++++++ Tests/Unit/CategoryScan.Tests.ps1 | 4 + Tests/Unit/CompareGroupAssignment.Tests.ps1 | 5 + Tests/Unit/DeviceAssignment.Tests.ps1 | 4 + Tests/Unit/GroupAssignment.Tests.ps1 | 4 + .../ImportedAdministrativeTemplates.Tests.ps1 | 1 + Tests/Unit/MobileAppScopeTags.Tests.ps1 | 12 ++ Tests/Unit/SearchPassThru.Tests.ps1 | 58 ++++++++ Tests/Unit/TestGroupMembership.Tests.ps1 | 4 + Tests/Unit/TestGroupRemoval.Tests.ps1 | 4 + Tests/Unit/UserAssignment.Tests.ps1 | 21 +++ 28 files changed, 726 insertions(+), 137 deletions(-) create mode 100644 Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml create mode 100644 Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/ConvertTo-IACNormalizedAssignment.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 create mode 100644 Tests/Unit/AssignmentRecord.Tests.ps1 create mode 100644 Tests/Unit/SearchPassThru.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml new file mode 100644 index 0000000..1c8a8e1 --- /dev/null +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml @@ -0,0 +1,31 @@ + + + + + IntuneAssignmentChecker.AssignmentRecord + + IntuneAssignmentChecker.AssignmentRecord + + + + 28 + 34 + 9 + 24 + 12 + + + + + Category + PolicyName + AssignmentMode + if ($_.TargetName) { $_.TargetName } elseif ($_.TargetId) { $_.TargetId } else { $_.TargetType } + Intent + + + + + + + diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index 43544b3..d19abed 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IntuneAssignmentChecker.psm1' - ModuleVersion = '4.3.2' + ModuleVersion = '4.4.0' GUID = 'c6e25ec6-5787-45ef-95af-8abeb8a17daf' Author = 'Ugur Koc' CompanyName = 'Community' @@ -32,6 +32,7 @@ CmdletsToExport = @() VariablesToExport = @() AliasesToExport = @('IntuneAssignmentChecker') + FormatsToProcess = @('IntuneAssignmentChecker.Format.ps1xml') FileList = @( 'Data/SettingDefinitions.json' 'html-export.ps1' @@ -43,6 +44,9 @@ ProjectUri = 'https://github.com/ugurkocde/IntuneAssignmentChecker' IconUri = '' ReleaseNotes = @' +Version 4.4.0: +- Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). + Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). - Restore Imported Administrative Template support across assignment views, simulations, search, CSV exports, and HTML reports; imported and mixed group policy configurations are included while built-in-only configurations remain excluded (issue #129). diff --git a/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 new file mode 100644 index 0000000..66d4ad9 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 @@ -0,0 +1,70 @@ +function ConvertTo-IACAssignmentRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Category, + [Parameter(Mandatory)] [object]$Entity, + [Parameter(Mandatory)] [object]$Assignment, + [string]$SubjectType, + [string]$SubjectId, + [string]$SubjectName, + [string]$Source = 'MicrosoftGraph', + [switch]$ResolveTargetName + ) + + $policyName = if (-not [string]::IsNullOrWhiteSpace($Entity.displayName)) { $Entity.displayName } + elseif (-not [string]::IsNullOrWhiteSpace($Entity.name)) { $Entity.name } + else { 'Unnamed Policy' } + + $targetType = if ($Assignment.TargetType) { "$($Assignment.TargetType)" } + else { + switch ("$($Assignment.Reason)") { + 'All Users' { 'AllUsers' } + 'All Devices' { 'AllDevices' } + 'Group Assignment' { 'Group' } + 'Direct Assignment' { 'Group' } + 'Group Exclusion' { 'Group' } + 'Direct Exclusion' { 'Group' } + 'No Assignment' { 'None' } + default { 'Unknown' } + } + } + $assignmentMode = if ($Assignment.AssignmentMode) { "$($Assignment.AssignmentMode)" } + elseif ($Assignment.Reason -in @('Group Exclusion', 'Direct Exclusion')) { 'Exclude' } + elseif ($Assignment.Reason -eq 'No Assignment') { 'None' } + else { 'Include' } + $targetId = if ($Assignment.TargetId) { $Assignment.TargetId } else { $Assignment.GroupId } + $targetName = switch ($targetType) { + 'AllUsers' { 'All Users' } + 'AllDevices' { 'All Devices' } + 'Group' { + if ($ResolveTargetName -and $targetId) { (Get-GroupInfo -GroupId $targetId).DisplayName } + else { $null } + } + default { $null } + } + $filter = if ($Assignment.FilterId -and $script:AssignmentFilterLookup -and $script:AssignmentFilterLookup.ContainsKey("$($Assignment.FilterId)")) { + $script:AssignmentFilterLookup["$($Assignment.FilterId)"] + } + else { $null } + $scopeTagIds = @($Entity.roleScopeTagIds | ForEach-Object { "$_" }) + $scopeTagNames = if ($scopeTagIds.Count -eq 0) { @('Default') } + elseif ($null -eq $script:ScopeTagLookup) { @($scopeTagIds | ForEach-Object { "Tag:$_" }) } + else { @((Get-ScopeTagNames -ScopeTagIds $scopeTagIds -ScopeTagLookup $script:ScopeTagLookup) -split ', ') } + $categoryName = if ($Category.ExportCategory) { $Category.ExportCategory } else { $Category.DisplayName } + + New-IACAssignmentRecord ` + -CategoryId "$($Category.Id)" -Category "$categoryName" ` + -PolicyId "$($Entity.id)" -PolicyName $policyName ` + -Platform (Get-PolicyPlatform -Policy $Entity) ` + -ScopeTagIds $scopeTagIds -ScopeTags $scopeTagNames ` + -AssignmentId $Assignment.AssignmentId ` + -AssignmentMode $assignmentMode ` + -TargetType $targetType -TargetId $targetId -TargetName $targetName ` + -Intent $Assignment.Intent -FilterId $Assignment.FilterId ` + -FilterName $(if ($filter) { $filter.Name } else { $null }) ` + -FilterMode $Assignment.FilterType ` + -FilterRule $(if ($filter) { $filter.Rule } else { $null }) ` + -FilterPlatform $(if ($filter) { $filter.Platform } else { $null }) ` + -SubjectType $SubjectType -SubjectId $SubjectId -SubjectName $SubjectName ` + -AssignmentReason $Assignment.Reason -Source $Source +} diff --git a/Module/IntuneAssignmentChecker/Private/ConvertTo-IACNormalizedAssignment.ps1 b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACNormalizedAssignment.ps1 new file mode 100644 index 0000000..9171134 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACNormalizedAssignment.ps1 @@ -0,0 +1,61 @@ +function ConvertTo-IACNormalizedAssignment { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Assignment, + [string[]]$GroupIds = @() + ) + + if (-not $Assignment.target -or -not $Assignment.target.'@odata.type') { return } + + $odataType = "$($Assignment.target.'@odata.type')" + $targetGroupId = if ($odataType -in @( + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget' + )) { "$($Assignment.target.groupId)" } else { $null } + + $reason = switch ($odataType) { + '#microsoft.graph.groupAssignmentTarget' { + if ($GroupIds.Count -eq 0) { 'Group Assignment' } + elseif ($GroupIds -contains $targetGroupId) { 'Direct Assignment' } + } + '#microsoft.graph.exclusionGroupAssignmentTarget' { + if ($GroupIds.Count -eq 0) { 'Group Exclusion' } + elseif ($GroupIds -contains $targetGroupId) { 'Direct Exclusion' } + } + '#microsoft.graph.allLicensedUsersAssignmentTarget' { + if ($GroupIds.Count -eq 0) { 'All Users' } + } + '#microsoft.graph.allDevicesAssignmentTarget' { + if ($GroupIds.Count -eq 0) { 'All Devices' } + } + } + if (-not $reason) { return } + + $filterId = $null + $filterType = $null + $rawFilterId = $Assignment.target.deviceAndAppManagementAssignmentFilterId + $rawFilterType = $Assignment.target.deviceAndAppManagementAssignmentFilterType + if ($rawFilterType -and $rawFilterType -ne 'none' -and $rawFilterId -and $rawFilterId -ne '00000000-0000-0000-0000-000000000000') { + $filterId = "$rawFilterId" + $filterType = "$rawFilterType" + } + + [PSCustomObject][ordered]@{ + AssignmentId = if ($null -ne $Assignment.id) { "$($Assignment.id)" } else { $null } + Reason = $reason + AssignmentMode = if ($odataType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { 'Exclude' } else { 'Include' } + TargetType = switch ($odataType) { + '#microsoft.graph.allLicensedUsersAssignmentTarget' { 'AllUsers' } + '#microsoft.graph.allDevicesAssignmentTarget' { 'AllDevices' } + '#microsoft.graph.groupAssignmentTarget' { 'Group' } + '#microsoft.graph.exclusionGroupAssignmentTarget' { 'Group' } + } + TargetId = $targetGroupId + GroupId = $targetGroupId + Intent = if ($null -ne $Assignment.intent) { "$($Assignment.intent)" } else { $null } + Apps = $null + FilterId = $filterId + FilterType = $filterType + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 index 573baf2..fbd50d5 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 @@ -75,71 +75,12 @@ function Get-IntuneAssignments { $assignmentList = if ($allAssignmentsForEntity) { $allAssignmentsForEntity } else { @() } foreach ($assignment in $assignmentList) { - $currentAssignmentReason = $null - $currentTargetGroupId = $null # Initialize to null - - if ($assignment.target -and $assignment.target.'@odata.type') { - $odataType = $assignment.target.'@odata.type' - - if ($odataType -eq '#microsoft.graph.groupAssignmentTarget') { - $currentTargetGroupId = $assignment.target.groupId - if ($effectiveGroupIds.Count -gt 0) { - # Specific group check requested - if ($effectiveGroupIds -contains $currentTargetGroupId) { - $currentAssignmentReason = "Direct Assignment" - } - } - else { - # No specific group, list all group assignments - $currentAssignmentReason = "Group Assignment" - } - } - elseif ($odataType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { - $currentTargetGroupId = $assignment.target.groupId - if ($effectiveGroupIds.Count -gt 0) { - # Specific group check requested - if ($effectiveGroupIds -contains $currentTargetGroupId) { - $currentAssignmentReason = "Direct Exclusion" - } - } - else { - # No specific group, list all group exclusions - $currentAssignmentReason = "Group Exclusion" - } - } - elseif ($effectiveGroupIds.Count -eq 0) { - # Only consider non-group assignments if NOT querying for a specific group - $currentAssignmentReason = switch ($odataType) { - '#microsoft.graph.allLicensedUsersAssignmentTarget' { "All Users" } - '#microsoft.graph.allDevicesAssignmentTarget' { "All Devices" } - default { $null } - } - } - } - else { + if (-not $assignment.target -or -not $assignment.target.'@odata.type') { Write-Warning "Assignment item for EntityId '$EntityId' (URI: $actualAssignmentsUri) is missing 'target' or 'target.@odata.type' property. Assignment data: $($assignment | ConvertTo-Json -Depth 3)" + continue } - - if ($currentAssignmentReason) { - $filterId = $null - $filterType = $null - if ($assignment.target) { - $rawFilterId = $assignment.target.deviceAndAppManagementAssignmentFilterId - $rawFilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType - if ($rawFilterType -and $rawFilterType -ne 'none' -and $rawFilterId -and $rawFilterId -ne '00000000-0000-0000-0000-000000000000') { - $filterId = $rawFilterId - $filterType = $rawFilterType - } - } - - $null = $assignmentsToReturn.Add([PSCustomObject]@{ - Reason = $currentAssignmentReason - GroupId = $currentTargetGroupId - Apps = $null # 'Apps' property is not directly available from general assignments endpoint - FilterId = $filterId - FilterType = $filterType - }) - } + $normalizedAssignment = ConvertTo-IACNormalizedAssignment -Assignment $assignment -GroupIds $effectiveGroupIds + if ($normalizedAssignment) { $null = $assignmentsToReturn.Add($normalizedAssignment) } } } catch { diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index 01d139c..dd2747d 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -3,6 +3,7 @@ function Invoke-IntuneCategoryScan { # PSReviewUnusedParameter cannot trace usage inside the nested helper functions below [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ProcessEntity')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'EntityPreFilter')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'BuildRecords')] param ( [Parameter(Mandatory = $true)] [object[]]$Categories, @@ -30,7 +31,10 @@ function Invoke-IntuneCategoryScan { # Caller-owned cache keyed by EntityType string; lets multi-target loops fetch # each entity set once per run. [Parameter(Mandatory = $false)] - [hashtable]$EntityCache + [hashtable]$EntityCache, + + [Parameter(Mandatory = $false)] + [switch]$BuildRecords ) if ($null -eq $EntityCache) { $EntityCache = @{} } @@ -45,6 +49,7 @@ function Invoke-IntuneCategoryScan { } $scanErrors = [System.Collections.Generic.List[object]]::new() + $records = [System.Collections.Generic.List[object]]::new() function Get-CachedEntitySet { param([string]$EntityType) @@ -67,55 +72,8 @@ function Invoke-IntuneCategoryScan { param([object[]]$RawAssignments) $normalized = [System.Collections.Generic.List[object]]::new() foreach ($assignment in $RawAssignments) { - $reason = $null - $targetGroupId = $null - $odataType = if ($assignment.target) { $assignment.target.'@odata.type' } else { $null } - - if ($odataType -eq '#microsoft.graph.groupAssignmentTarget') { - $targetGroupId = $assignment.target.groupId - if ($AssignmentGroupIds.Count -gt 0) { - if ($AssignmentGroupIds -contains $targetGroupId) { $reason = 'Direct Assignment' } - } - else { - $reason = 'Group Assignment' - } - } - elseif ($odataType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { - $targetGroupId = $assignment.target.groupId - if ($AssignmentGroupIds.Count -gt 0) { - if ($AssignmentGroupIds -contains $targetGroupId) { $reason = 'Direct Exclusion' } - } - else { - $reason = 'Group Exclusion' - } - } - elseif ($AssignmentGroupIds.Count -eq 0) { - $reason = switch ($odataType) { - '#microsoft.graph.allLicensedUsersAssignmentTarget' { 'All Users' } - '#microsoft.graph.allDevicesAssignmentTarget' { 'All Devices' } - default { $null } - } - } - - if ($reason) { - $filterId = $null - $filterType = $null - if ($assignment.target) { - $rawFilterId = $assignment.target.deviceAndAppManagementAssignmentFilterId - $rawFilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType - if ($rawFilterType -and $rawFilterType -ne 'none' -and $rawFilterId -and $rawFilterId -ne '00000000-0000-0000-0000-000000000000') { - $filterId = $rawFilterId - $filterType = $rawFilterType - } - } - - $normalized.Add([PSCustomObject]@{ - Reason = $reason - GroupId = $targetGroupId - FilterId = $filterId - FilterType = $filterType - }) - } + $item = ConvertTo-IACNormalizedAssignment -Assignment $assignment -GroupIds $AssignmentGroupIds + if ($item) { $normalized.Add($item) } } return , $normalized } @@ -128,11 +86,29 @@ function Invoke-IntuneCategoryScan { function Invoke-ProcessEntityCallback { param($Category, $Entity, $Assignments, $RawAssignments) + $entityRecords = [System.Collections.Generic.List[object]]::new() + if ($BuildRecords) { + if ($Assignments.Count -eq 0) { + $noneAssignment = [PSCustomObject]@{ + AssignmentId = $null; Reason = 'No Assignment'; AssignmentMode = 'None' + TargetType = 'None'; TargetId = $null; GroupId = $null; Intent = $null + FilterId = $null; FilterType = $null + } + $entityRecords.Add((ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $noneAssignment)) + } + else { + foreach ($assignment in $Assignments) { + $entityRecords.Add((ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $assignment)) + } + } + foreach ($record in $entityRecords) { $records.Add($record) } + } $context = [PSCustomObject]@{ Category = $Category Entity = $Entity Assignments = $Assignments RawAssignments = $RawAssignments + Records = $entityRecords Buckets = $buckets } & $ProcessEntity $context @@ -249,6 +225,7 @@ function Invoke-IntuneCategoryScan { return [PSCustomObject]@{ Buckets = $buckets + Records = $records Errors = $scanErrors CategoryCount = $totalCategories } diff --git a/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 new file mode 100644 index 0000000..4b3a372 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 @@ -0,0 +1,69 @@ +function New-IACAssignmentRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$CategoryId, + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Category, + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$PolicyId, + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$PolicyName, + [string]$Platform = 'Unknown', + [string[]]$ScopeTagIds = @(), + [string[]]$ScopeTags = @(), + [string]$AssignmentId, + [ValidateSet('Include', 'Exclude', 'None', 'Unknown')] + [string]$AssignmentMode = 'Unknown', + [ValidateSet('AllUsers', 'AllDevices', 'Group', 'None', 'Unknown')] + [string]$TargetType = 'Unknown', + [string]$TargetId, + [string]$TargetName, + [string]$Intent, + [string]$FilterId, + [string]$FilterName, + [string]$FilterMode, + [string]$FilterRule, + [string]$FilterPlatform, + [string]$SubjectType, + [string]$SubjectId, + [string]$SubjectName, + [string]$AssignmentReason, + [string]$Source = 'MicrosoftGraph' + ) + + $record = [PSCustomObject][ordered]@{ + SchemaVersion = 1 + TenantId = $script:CurrentTenantId + TenantName = $script:CurrentTenantName + SubjectType = $SubjectType + SubjectId = $SubjectId + SubjectName = $SubjectName + CategoryId = $CategoryId + Category = $Category + PolicyId = $PolicyId + PolicyName = $PolicyName + Platform = $Platform + ScopeTagIds = @($ScopeTagIds | ForEach-Object { "$_" }) + ScopeTags = @($ScopeTags | ForEach-Object { "$_" }) + AssignmentId = $AssignmentId + AssignmentMode = $AssignmentMode + TargetType = $TargetType + TargetId = $TargetId + TargetName = $TargetName + Intent = $Intent + FilterId = $FilterId + FilterName = $FilterName + FilterMode = $FilterMode + FilterRule = $FilterRule + FilterPlatform = $FilterPlatform + AssignmentReason = $AssignmentReason + Source = $Source + } + $record.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentRecord') + return $record +} diff --git a/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 new file mode 100644 index 0000000..999a588 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 @@ -0,0 +1,48 @@ +function Select-IACAssignmentRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Records, + [Parameter(Mandatory)] + [hashtable]$Buckets, + [AllowEmptyCollection()] + [string[]]$TargetTypes, + [AllowEmptyCollection()] + [string[]]$GroupIds, + [string]$SubjectType, + [string]$SubjectId, + [string]$SubjectName, + [Parameter(Mandatory)] + [string]$Source + ) + + $visiblePolicyIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($bucket in $Buckets.Values) { + foreach ($entity in @($bucket)) { + $entityPolicyId = if ($entity.id) { $entity.id } else { $entity.PolicyId } + if ($entityPolicyId) { $null = $visiblePolicyIds.Add("$entityPolicyId") } + } + } + + $restrictGroupIds = $PSBoundParameters.ContainsKey('GroupIds') + foreach ($record in $Records) { + if (-not $visiblePolicyIds.Contains("$($record.PolicyId)")) { continue } + if ($TargetTypes -and $record.TargetType -notin $TargetTypes) { continue } + if ($record.TargetType -eq 'Group' -and $restrictGroupIds -and $record.TargetId -notin $GroupIds) { continue } + + $copy = [PSCustomObject][ordered]@{} + foreach ($property in $record.PSObject.Properties) { + $copy | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $copy.SubjectType = $SubjectType + $copy.SubjectId = $SubjectId + $copy.SubjectName = $SubjectName + $copy.Source = $Source + if ($copy.TargetType -eq 'Group' -and $copy.TargetId -and -not $copy.TargetName) { + $copy.TargetName = (Get-GroupInfo -GroupId $copy.TargetId).DisplayName + } + $copy.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentRecord') + $copy + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 index 0ee7374..a487954 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 @@ -1,5 +1,6 @@ function Get-IntuneAllDevicesAssignment { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param ( [Parameter()] [switch]$ExportToCSV, @@ -8,7 +9,10 @@ function Get-IntuneAllDevicesAssignment { [string]$ExportPath, [Parameter()] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter()] + [switch]$PassThru ) Write-Host "Fetching all 'All Devices' assignments..." -ForegroundColor Green @@ -87,7 +91,7 @@ function Get-IntuneAllDevicesAssignment { } } - $scanResult = Invoke-IntuneCategoryScan -Categories $scanCategories -ProcessEntity $processEntity -ShowProgress + $scanResult = Invoke-IntuneCategoryScan -Categories $scanCategories -ProcessEntity $processEntity -ShowProgress -BuildRecords:$PassThru $allDevicesAssignments = $scanResult.Buckets # Apply scope tag filter if specified @@ -155,5 +159,9 @@ function Get-IntuneAllDevicesAssignment { Add-CategoryExportData -ExportData $exportData -Categories $exportCategories -Buckets $allDevicesAssignments -AssignmentReason "All Devices" # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllDevicesAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllDevicesAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { + Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $allDevicesAssignments ` + -TargetTypes @('AllDevices') -SubjectType 'Tenant' -SubjectName 'All Devices' -Source 'Get-IntuneAllDevicesAssignment' + } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 index 3d2c85d..83315c9 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 @@ -1,5 +1,6 @@ function Get-IntuneAllPolicies { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param ( [Parameter()] [switch]$ExportToCSV, @@ -8,7 +9,10 @@ function Get-IntuneAllPolicies { [string]$ExportPath, [Parameter()] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter()] + [switch]$PassThru ) Write-Host "Fetching all policies and their assignments..." -ForegroundColor Green @@ -107,7 +111,7 @@ function Get-IntuneAllPolicies { $ctx.Buckets[$bucketKey].Add($entity) } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress -BuildRecords:$PassThru $allPolicies = $scanResult.Buckets # Apply scope tag filter if specified @@ -141,5 +145,8 @@ function Get-IntuneAllPolicies { Add-CategoryExportData -ExportData $exportData -Categories $categories -Buckets $allPolicies -AssignmentReason { param($item) $item.AssignmentSummary } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllPolicies.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllPolicies.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { + Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $allPolicies -SubjectType 'Tenant' -SubjectName 'All Policies' -Source 'Get-IntuneAllPolicies' + } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 index b12eb73..c86b027 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 @@ -1,5 +1,6 @@ function Get-IntuneAllUsersAssignment { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param ( [Parameter()] [switch]$ExportToCSV, @@ -8,7 +9,10 @@ function Get-IntuneAllUsersAssignment { [string]$ExportPath, [Parameter()] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter()] + [switch]$PassThru ) Write-Host "Fetching all 'All Users' assignments..." -ForegroundColor Green @@ -76,7 +80,7 @@ function Get-IntuneAllUsersAssignment { } } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress -BuildRecords:$PassThru $allUsersAssignments = $scanResult.Buckets # Apply scope tag filter if specified @@ -189,5 +193,9 @@ function Get-IntuneAllUsersAssignment { Add-CategoryExportData -ExportData $exportData -Categories $exportCategories -Buckets $allUsersAssignments -AssignmentReason "All Users" # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllUsersAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneAllUsersAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { + Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $allUsersAssignments ` + -TargetTypes @('AllUsers') -SubjectType 'Tenant' -SubjectName 'All Users' -Source 'Get-IntuneAllUsersAssignment' + } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 index 10f5430..3823c9f 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 @@ -1,5 +1,6 @@ function Get-IntuneDeviceAssignment { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param( [Parameter(Mandatory = $false)] [string]$DeviceNames, @@ -11,7 +12,10 @@ function Get-IntuneDeviceAssignment { [string]$ExportPath, [Parameter(Mandatory = $false)] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter(Mandatory = $false)] + [switch]$PassThru ) Write-Host "Device selection chosen" -ForegroundColor Green @@ -36,6 +40,7 @@ function Get-IntuneDeviceAssignment { # back into one space-joined string and break multi-device input. $deviceNameList = $deviceInput -split ',' | ForEach-Object { $_.Trim() } $exportData = [System.Collections.ArrayList]::new() + $passThruRecords = [System.Collections.Generic.List[object]]::new() $categories = Get-IntuneCategoryDefinition -Audience 'DeviceContext' # Categories the legacy code fetched only for Windows (or unknown-OS) devices @@ -326,7 +331,7 @@ function Get-IntuneDeviceAssignment { } } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -EntityPreFilter $entityPreFilter -ShowProgress -EntityCache $entityCache + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -EntityPreFilter $entityPreFilter -ShowProgress -EntityCache $entityCache -BuildRecords:$PassThru $relevantPolicies = $scanResult.Buckets # Apply scope tag filter if specified @@ -336,6 +341,13 @@ function Get-IntuneDeviceAssignment { } } + if ($PassThru) { + $selectedRecords = @(Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $relevantPolicies ` + -TargetTypes @('AllDevices', 'Group') -GroupIds @($groupMemberships.id) ` + -SubjectType 'Device' -SubjectId $deviceInfo.Id -SubjectName $deviceInfo.DisplayName -Source 'Get-IntuneDeviceAssignment') + foreach ($record in $selectedRecords) { $passThruRecords.Add($record) } + } + # Display results Write-Host "`nAssignments for Device: $deviceName" -ForegroundColor Green @@ -395,5 +407,6 @@ function Get-IntuneDeviceAssignment { } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneDeviceAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneDeviceAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { $passThruRecords } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 index 827658b..56172d0 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 @@ -1,5 +1,6 @@ function Get-IntuneGroupAssignment { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param( [Parameter(Mandatory = $false)] [string]$GroupNames, @@ -14,7 +15,10 @@ function Get-IntuneGroupAssignment { [string]$ExportPath, [Parameter(Mandatory = $false)] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter(Mandatory = $false)] + [switch]$PassThru ) Write-Host "Group selection chosen" -ForegroundColor Green @@ -37,6 +41,7 @@ function Get-IntuneGroupAssignment { $groupInputs = $groupInput -split ',' | ForEach-Object { $_.Trim() } $exportData = [System.Collections.ArrayList]::new() + $passThruRecords = [System.Collections.Generic.List[object]]::new() # Determine if nested group checking should be enabled $checkNestedGroups = $false @@ -193,7 +198,7 @@ function Get-IntuneGroupAssignment { } } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -AssignmentGroupIds $allGroupIds -ShowProgress -EntityCache $entityCache + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -AssignmentGroupIds $allGroupIds -ShowProgress -EntityCache $entityCache -BuildRecords:$PassThru $relevantPolicies = $scanResult.Buckets # Apply scope tag filter if specified @@ -203,6 +208,13 @@ function Get-IntuneGroupAssignment { } } + if ($PassThru) { + $selectedRecords = @(Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $relevantPolicies ` + -TargetTypes @('Group') -GroupIds $allGroupIds ` + -SubjectType 'Group' -SubjectId $groupId -SubjectName $groupName -Source 'Get-IntuneGroupAssignment') + foreach ($record in $selectedRecords) { $passThruRecords.Add($record) } + } + # Display sections in the legacy order with the legacy per-category name resolution. # Sections without GetName use the Show-CategoryResultTable default # (displayName, then name, then "Unnamed Profile"). @@ -275,5 +287,6 @@ function Get-IntuneGroupAssignment { } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { $passThruRecords } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 index be1ce18..c9d256d 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 @@ -1,5 +1,6 @@ function Get-IntuneUnassignedPolicy { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param ( [Parameter()] [switch]$ExportToCSV, @@ -8,7 +9,10 @@ function Get-IntuneUnassignedPolicy { [string]$ExportPath, [Parameter()] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter()] + [switch]$PassThru ) Write-Host "Fetching policies without assignments..." -ForegroundColor Green @@ -24,6 +28,12 @@ function Get-IntuneUnassignedPolicy { AppConfigurationPolicies = @() PlatformScripts = @() HealthScripts = @() + AntivirusProfiles = @() + DiskEncryptionProfiles = @() + FirewallProfiles = @() + EndpointDetectionProfiles = @() + AttackSurfaceProfiles = @() + AccountProtectionProfiles = @() Apps = @() } @@ -459,5 +469,37 @@ function Get-IntuneUnassignedPolicy { } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUnassignedPolicies.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUnassignedPolicies.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { + $categoryMap = [ordered]@{ + DeviceConfigs = @('DeviceConfigurations', 'Device Configuration') + ImportedAdministrativeTemplates = @('ImportedAdministrativeTemplates', 'Imported Administrative Template') + SettingsCatalog = @('SettingsCatalog', 'Settings Catalog Policy') + CompliancePolicies = @('CompliancePolicies', 'Compliance Policy') + AppProtectionPolicies = @('AppProtectionPolicies', 'App Protection Policy') + AppConfigurationPolicies = @('AppConfigurationPolicies', 'App Configuration Policy') + PlatformScripts = @('PlatformScripts', 'Platform Scripts') + HealthScripts = @('HealthScripts', 'Proactive Remediation Scripts') + AntivirusProfiles = @('ESAntivirus', 'Endpoint Security - Antivirus') + DiskEncryptionProfiles = @('ESDiskEncryption', 'Endpoint Security - Disk Encryption') + FirewallProfiles = @('ESFirewall', 'Endpoint Security - Firewall') + EndpointDetectionProfiles = @('ESEndpointDetection', 'Endpoint Security - EDR') + AttackSurfaceProfiles = @('ESAttackSurface', 'Endpoint Security - ASR') + AccountProtectionProfiles = @('ESAccountProtection', 'Endpoint Security - Account Protection') + Apps = @('Applications', 'Application') + } + foreach ($bucketName in $categoryMap.Keys) { + foreach ($entity in @($unassignedPolicies[$bucketName])) { + $scopeTagIds = @($entity.roleScopeTagIds | ForEach-Object { "$_" }) + $scopeTags = if ($scopeTagIds.Count -eq 0) { @('Default') } + elseif ($script:ScopeTagLookup) { @((Get-ScopeTagNames -ScopeTagIds $scopeTagIds -ScopeTagLookup $script:ScopeTagLookup) -split ', ') } + else { @($scopeTagIds | ForEach-Object { "Tag:$_" }) } + $policyName = if ($entity.displayName) { $entity.displayName } elseif ($entity.name) { $entity.name } else { 'Unnamed Policy' } + New-IACAssignmentRecord -CategoryId $categoryMap[$bucketName][0] -Category $categoryMap[$bucketName][1] ` + -PolicyId "$($entity.id)" -PolicyName $policyName -Platform (Get-PolicyPlatform -Policy $entity) ` + -ScopeTagIds $scopeTagIds -ScopeTags $scopeTags -AssignmentMode None -TargetType None ` + -SubjectType Tenant -SubjectName Unassigned -AssignmentReason 'No Assignment' -Source 'Get-IntuneUnassignedPolicy' + } + } + } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 index d01dd45..27d13ab 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 @@ -1,5 +1,6 @@ function Get-IntuneUserAssignment { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param( [Parameter(Mandatory = $false)] [string]$UserPrincipalNames, @@ -11,7 +12,10 @@ function Get-IntuneUserAssignment { [string]$ExportPath, [Parameter(Mandatory = $false)] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter(Mandatory = $false)] + [switch]$PassThru ) Write-Host "User selection chosen" -ForegroundColor Green @@ -54,6 +58,7 @@ function Get-IntuneUserAssignment { } $exportData = [System.Collections.ArrayList]::new() + $passThruRecords = [System.Collections.Generic.List[object]]::new() # Renders one legacy three-column section (name/ID/assignment). The Device # Configurations and App Protection sections keep their bespoke extra column below. @@ -240,7 +245,7 @@ function Get-IntuneUserAssignment { $memberGroupIds = @($groupMemberships.id) $appProgress = @{ Current = 0; Total = $null; FilteredTotal = $null } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress -EntityCache $entityCache + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress -EntityCache $entityCache -BuildRecords:$PassThru $relevantPolicies = $scanResult.Buckets # Apply scope tag filter if specified @@ -250,6 +255,13 @@ function Get-IntuneUserAssignment { } } + if ($PassThru) { + $selectedRecords = @(Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $relevantPolicies ` + -TargetTypes @('AllUsers', 'Group') -GroupIds $memberGroupIds ` + -SubjectType 'User' -SubjectId $userInfo.Id -SubjectName $upn -Source 'Get-IntuneUserAssignment') + foreach ($record in $selectedRecords) { $passThruRecords.Add($record) } + } + # Display results Write-Host "`nAssignments for User: $upn" -ForegroundColor Green @@ -378,5 +390,6 @@ function Get-IntuneUserAssignment { } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUserAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUserAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { $passThruRecords } } diff --git a/Module/IntuneAssignmentChecker/Public/Search-IntunePolicy.ps1 b/Module/IntuneAssignmentChecker/Public/Search-IntunePolicy.ps1 index 5ed73e4..a485b9d 100644 --- a/Module/IntuneAssignmentChecker/Public/Search-IntunePolicy.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Search-IntunePolicy.ps1 @@ -1,5 +1,6 @@ function Search-IntunePolicy { [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] param( [Parameter()] [string]$PolicySearchTerm, @@ -8,7 +9,10 @@ function Search-IntunePolicy { [switch]$ExportToCSV, [Parameter()] - [string]$ExportPath + [string]$ExportPath, + + [Parameter()] + [switch]$PassThru ) Write-Host "Policy Search / Reverse Lookup selected" -ForegroundColor Green @@ -199,7 +203,7 @@ function Search-IntunePolicy { Resolve-SearchAssignments -Assignments $ctx.Assignments -CategoryLabel $ctx.Category.ExportCategory -PolicyName $policyName -PolicyId $ctx.Entity.id -Results $results } - $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -EntityPreFilter $entityPreFilter -ShowProgress -ProgressVerb 'Searching' + $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -EntityPreFilter $entityPreFilter -ShowProgress -ProgressVerb 'Searching' -BuildRecords:$PassThru $allSearchResults = $scanResult.Buckets['SearchResults'] # --- Display Results --- @@ -281,5 +285,9 @@ function Search-IntunePolicy { }) } - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntunePolicySearch.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntunePolicySearch.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:($parameterMode -or $PassThru) + if ($PassThru) { + Select-IACAssignmentRecord -Records $scanResult.Records -Buckets $scanResult.Buckets ` + -SubjectType 'Search' -SubjectName $searchTerm -Source 'Search-IntunePolicy' + } } diff --git a/README.md b/README.md index 1449032..788aa98 100644 --- a/README.md +++ b/README.md @@ -383,8 +383,33 @@ Search-IntunePolicy -PolicySearchTerm "BitLocker" # Search configured settings across policies (Settings Catalog + Endpoint Security) Search-IntuneSetting -SearchTerm "BitLocker" + +# Return automation-friendly objects while retaining the normal console experience +$records = Get-IntuneAllPolicies -PassThru +$records | Where-Object AssignmentMode -eq 'Exclude' ``` +`Get-IntuneUserAssignment`, `Get-IntuneGroupAssignment`, +`Get-IntuneDeviceAssignment`, `Get-IntuneAllPolicies`, +`Get-IntuneAllUsersAssignment`, `Get-IntuneAllDevicesAssignment`, +`Get-IntuneUnassignedPolicy`, and `Search-IntunePolicy` support `-PassThru`. +Using it also suppresses the interactive CSV-export prompt. Each object has the type name +`IntuneAssignmentChecker.AssignmentRecord` and schema version `1`. The stable +contract includes tenant and subject metadata, policy/category/platform, scope +tags, assignment target and include/exclude mode, application intent, assignment +filter metadata, the display reason, and source command. Console messages remain +on the information stream, so they do not contaminate pipeline object output. +Additive fields may be introduced without changing `SchemaVersion`; removing or +renaming a field, changing its meaning, or changing an enum value requires a schema +version increment. The existing CSV and HTML schemas remain backward-compatible; +shared-scan cmdlets create canonical records from the same structured Graph data +used for their console and CSV views, while the HTML report keeps its purpose-built +flat reporting schema. Treat `CategoryId` as the stable machine key; `Category` is +a presentation label and can vary where a cmdlet distinguishes app intents or uses +search-specific wording. `Get-IntuneUserDeviceAssignment` intentionally keeps its +legacy output in this release slice; the v4.4 effective-targeting cmdlet introduced +in issue #140 provides canonical user/device results. + `Get-IntuneGroupAssignment` CSV/Excel exports include `GroupId`, `GroupName`, `GroupType`, `MembershipType`, and `GroupMail` on every group and policy/app row. This keeps multi-group exports attributable and lets workbooks distinguish @@ -430,6 +455,7 @@ Common parameters on assignment cmdlets: | `-ExportToCSV` | Export results to CSV | | `-ExportPath` | Path to export the CSV file | | `-ScopeTagFilter` | Filter results by scope tag name | +| `-PassThru` | Return `IntuneAssignmentChecker.AssignmentRecord` objects | Common parameters on `Connect-IntuneAssignmentChecker`: diff --git a/Tests/Unit/AssignmentRecord.Tests.ps1 b/Tests/Unit/AssignmentRecord.Tests.ps1 new file mode 100644 index 0000000..e6a51de --- /dev/null +++ b/Tests/Unit/AssignmentRecord.Tests.ps1 @@ -0,0 +1,133 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $modulePrivate = Join-Path $moduleRoot 'Private' + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') + + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + $script:ScopeTagLookup = @{ '1' = 'Finance'; '2' = 'Security' } + $script:AssignmentFilterLookup = @{ + 'filter-1' = [PSCustomObject]@{ + Name = 'Corporate Windows'; Platform = 'windows10AndLater' + Rule = '(device.deviceOwnership -eq "Corporate")' + } + } + function Get-GroupInfo { + param([string]$GroupId) + [PSCustomObject]@{ DisplayName = 'Finance Devices' } + } +} + +Describe 'IntuneAssignmentChecker.AssignmentRecord' { + It 'pins schema version 1, property order, and the PowerShell type name' { + $record = New-IACAssignmentRecord -CategoryId Configuration -Category 'Configuration Policy' -PolicyId policy-1 -PolicyName Baseline -AssignmentMode Include -TargetType AllDevices -TargetName 'All Devices' + + $record.PSObject.TypeNames[0] | Should -BeExactly 'IntuneAssignmentChecker.AssignmentRecord' + $record.SchemaVersion | Should -Be 1 + $record.TenantId | Should -BeExactly 'tenant-1' + @($record.PSObject.Properties.Name) | Should -Be @( + 'SchemaVersion', 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', + 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', + 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', + 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', + 'AssignmentReason', 'Source' + ) + } + + It 'allows unnamed or partial Graph entities without aborting the pipeline' { + { New-IACAssignmentRecord -CategoryId '' -Category '' -PolicyId '' -PolicyName '' -AssignmentMode None -TargetType None } | Should -Not -Throw + } + + It 'keeps scope tag ids and names in matching source order' { + $record = New-IACAssignmentRecord -CategoryId c -Category c -PolicyId p -PolicyName n -ScopeTagIds @('2', '1') -ScopeTags @('Security', 'Finance') + $record.ScopeTagIds | Should -Be @('2', '1') + $record.ScopeTags | Should -Be @('Security', 'Finance') + } + + It 'normalizes Graph targets once without confusing filter mode and assignment mode' { + $raw = [PSCustomObject]@{ + id = 'assignment-1'; intent = 'required' + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'group-1' + deviceAndAppManagementAssignmentFilterId = 'filter-1' + deviceAndAppManagementAssignmentFilterType = 'exclude' + } + } + $assignment = ConvertTo-IACNormalizedAssignment -Assignment $raw + + $assignment.AssignmentMode | Should -BeExactly Include + $assignment.TargetType | Should -BeExactly Group + $assignment.TargetId | Should -BeExactly group-1 + $assignment.FilterType | Should -BeExactly exclude + $assignment.Intent | Should -BeExactly required + } + + It 'filters group targets structurally even when a group name contains reserved words' { + $raw = [PSCustomObject]@{ id = 'a'; target = [PSCustomObject]@{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'group-all-users-exclusions' } } + $assignment = ConvertTo-IACNormalizedAssignment -Assignment $raw -GroupIds @('group-all-users-exclusions') + $assignment.AssignmentMode | Should -BeExactly Include + $assignment.TargetType | Should -BeExactly Group + $assignment.Reason | Should -BeExactly 'Direct Assignment' + } + + It 'hydrates policy, platform, filter, target, and scope metadata from structured data' { + $category = [PSCustomObject]@{ Id = 'SettingsCatalog'; ExportCategory = 'Settings Catalog Policy'; DisplayName = 'Settings Catalog' } + $entity = [PSCustomObject]@{ + id = 'policy-1'; name = 'Security baseline'; roleScopeTagIds = @('1') + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationPolicy'; platforms = @('windows10') + } + $assignment = [PSCustomObject]@{ + AssignmentId = 'assignment-1'; Reason = 'Group Assignment'; AssignmentMode = 'Include' + TargetType = 'Group'; TargetId = 'group-1'; GroupId = 'group-1'; Intent = 'required' + FilterId = 'filter-1'; FilterType = 'exclude' + } + + $record = ConvertTo-IACAssignmentRecord -Category $category -Entity $entity -Assignment $assignment -ResolveTargetName + $record.CategoryId | Should -BeExactly SettingsCatalog + $record.TargetName | Should -BeExactly 'Finance Devices' + $record.FilterName | Should -BeExactly 'Corporate Windows' + $record.FilterRule | Should -Match deviceOwnership + $record.ScopeTags | Should -Be @('Finance') + $record.Platform | Should -BeExactly windows10 + } + + It 'selects visible records, applies subject context, and streams an empty result safely' { + $record = New-IACAssignmentRecord -CategoryId SettingsCatalog -Category Policy -PolicyId p1 -PolicyName One -AssignmentMode Include -TargetType AllUsers + $buckets = @{ Results = @([PSCustomObject]@{ id = 'p1' }) } + $selected = @(Select-IACAssignmentRecord -Records @($record) -Buckets $buckets -TargetTypes AllUsers -SubjectType User -SubjectId u1 -Source Test) + $empty = @(Select-IACAssignmentRecord -Records @() -Buckets @{} -SubjectType User -Source Test) + $groupRecord = New-IACAssignmentRecord -CategoryId c -Category c -PolicyId p1 -PolicyName One -AssignmentMode Include -TargetType Group -TargetId g1 + $noGroupMatch = @(Select-IACAssignmentRecord -Records @($groupRecord) -Buckets $buckets -TargetTypes Group -GroupIds @() -SubjectType User -Source Test) + + $selected.Count | Should -Be 1 + $selected[0].SubjectId | Should -BeExactly u1 + $selected[0].PSObject.TypeNames[0] | Should -BeExactly 'IntuneAssignmentChecker.AssignmentRecord' + $empty.Count | Should -Be 0 + $noGroupMatch.Count | Should -Be 0 + } + + It 'exposes PassThru and OutputType on the supported primary commands' { + $commandFiles = @( + 'Get-IntuneUserAssignment.ps1', 'Get-IntuneGroupAssignment.ps1', + 'Get-IntuneDeviceAssignment.ps1', 'Get-IntuneAllPolicies.ps1', + 'Get-IntuneAllUsersAssignment.ps1', 'Get-IntuneAllDevicesAssignment.ps1', + 'Get-IntuneUnassignedPolicy.ps1', 'Search-IntunePolicy.ps1' + ) + foreach ($file in $commandFiles) { + $errors = $null; $tokens = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $moduleRoot "Public/$file"), [ref]$tokens, [ref]$errors) + $errors | Should -BeNullOrEmpty + $parameters = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.ParameterAst] }, $true) + @($parameters.Name.VariablePath.UserPath) | Should -Contain PassThru + Get-Content -Raw (Join-Path $moduleRoot "Public/$file") | Should -Match "\[OutputType\('IntuneAssignmentChecker\.AssignmentRecord'\)\]" + } + } +} diff --git a/Tests/Unit/CategoryScan.Tests.ps1 b/Tests/Unit/CategoryScan.Tests.ps1 index 41c69d3..470b58a 100644 --- a/Tests/Unit/CategoryScan.Tests.ps1 +++ b/Tests/Unit/CategoryScan.Tests.ps1 @@ -7,6 +7,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') . (Join-Path $modulePrivate 'Add-ExportData.ps1') diff --git a/Tests/Unit/CompareGroupAssignment.Tests.ps1 b/Tests/Unit/CompareGroupAssignment.Tests.ps1 index ab6cb29..4e37472 100644 --- a/Tests/Unit/CompareGroupAssignment.Tests.ps1 +++ b/Tests/Unit/CompareGroupAssignment.Tests.ps1 @@ -8,6 +8,11 @@ BeforeAll { . (Join-Path $modulePrivate 'ConvertTo-IntuneGroupInfo.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Format-AssignmentFilter.ps1') . (Join-Path $moduleRoot 'Public/Compare-IntuneGroupAssignment.ps1') diff --git a/Tests/Unit/DeviceAssignment.Tests.ps1 b/Tests/Unit/DeviceAssignment.Tests.ps1 index d88a136..7f741d6 100644 --- a/Tests/Unit/DeviceAssignment.Tests.ps1 +++ b/Tests/Unit/DeviceAssignment.Tests.ps1 @@ -16,6 +16,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Add-ExportData.ps1') . (Join-Path $modulePrivate 'Add-CategoryExportData.ps1') diff --git a/Tests/Unit/GroupAssignment.Tests.ps1 b/Tests/Unit/GroupAssignment.Tests.ps1 index 51cc2a7..c389f83 100644 --- a/Tests/Unit/GroupAssignment.Tests.ps1 +++ b/Tests/Unit/GroupAssignment.Tests.ps1 @@ -13,6 +13,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Get-GroupAssignmentReasons.ps1') . (Join-Path $modulePrivate 'Format-AssignmentFilter.ps1') diff --git a/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 b/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 index 47c7164..6ce8d69 100644 --- a/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 +++ b/Tests/Unit/ImportedAdministrativeTemplates.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' $modulePrivate = Join-Path $moduleRoot 'Private' + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') . (Join-Path $modulePrivate 'Get-IntuneAssignments.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') diff --git a/Tests/Unit/MobileAppScopeTags.Tests.ps1 b/Tests/Unit/MobileAppScopeTags.Tests.ps1 index 81f2b31..1dab0b7 100644 --- a/Tests/Unit/MobileAppScopeTags.Tests.ps1 +++ b/Tests/Unit/MobileAppScopeTags.Tests.ps1 @@ -6,6 +6,8 @@ BeforeAll { $modulePrivate = Join-Path $moduleRoot 'Private' . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Add-ExportData.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $moduleRoot 'Public/Get-IntuneUnassignedPolicy.ps1') @@ -85,6 +87,16 @@ Describe 'Mobile application scope tags' { } } + It 'returns canonical unassigned records without errors when Endpoint Security buckets are empty' { + $records = @(Get-IntuneUnassignedPolicy -PassThru -ErrorAction Stop) + + $records.Count | Should -Be 1 + $records[0].PSObject.TypeNames[0] | Should -BeExactly 'IntuneAssignmentChecker.AssignmentRecord' + $records[0].CategoryId | Should -BeExactly Applications + $records[0].AssignmentMode | Should -BeExactly None + $records[0].ScopeTagIds | Should -Be @('0', 'tag-finance') + } + It 'exports unassigned custom and mixed imported templates but never queries built-in-only assignments' { Mock Get-IntuneEntities { if ($EntityType -eq 'groupPolicyConfigurations') { diff --git a/Tests/Unit/SearchPassThru.Tests.ps1 b/Tests/Unit/SearchPassThru.Tests.ps1 new file mode 100644 index 0000000..c20c4de --- /dev/null +++ b/Tests/Unit/SearchPassThru.Tests.ps1 @@ -0,0 +1,58 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $private = Join-Path $moduleRoot Private + foreach ($name in @( + 'Get-PolicyPlatform.ps1', 'Get-ScopeTagNames.ps1', 'New-IACAssignmentRecord.ps1', + 'ConvertTo-IACAssignmentRecord.ps1', 'ConvertTo-IACNormalizedAssignment.ps1', + 'Select-IACAssignmentRecord.ps1', 'Format-AssignmentFilter.ps1', 'Get-Separator.ps1', + 'Get-AppProtectionAssignmentUri.ps1', 'Test-ImportedAdministrativeTemplate.ps1', + 'Get-IntuneCategoryDefinition.ps1', 'Invoke-IntuneCategoryScan.ps1')) { + . (Join-Path $private $name) + } + . (Join-Path $moduleRoot 'Public/Search-IntunePolicy.ps1') + + $script:GraphEndpoint = 'https://graph.test' + $script:ScopeTagLookup = @{} + $script:AssignmentFilterLookup = @{} + function Get-IntuneEntities { param([string]$EntityType) @() } + function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId, [string[]]$GroupIds = @()) @() } + function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } + function Get-GroupInfo { param([string]$GroupId) [PSCustomObject]@{ DisplayName = 'Group' } } + function Export-ResultsIfRequested { param($ExportData, $DefaultFileName, $ForceExport, $CustomExportPath, $ExportToCSV, $ParameterMode) } +} + +Describe 'Search-IntunePolicy PassThru' { + BeforeEach { + Mock Write-Host {} + Mock Get-IntuneEntities { + if ($EntityType -eq 'deviceConfigurations') { + @( + [PSCustomObject]@{ id = 'assigned'; displayName = 'Baseline Assigned' } + [PSCustomObject]@{ id = 'empty'; displayName = 'Baseline Empty' } + ) + } + else { @() } + } + Mock Get-IntuneAssignments { + if ($EntityId -eq 'assigned') { + @([PSCustomObject]@{ Reason = 'All Users'; AssignmentMode = 'Include'; TargetType = 'AllUsers' }) + } + else { @() } + } + Mock Export-ResultsIfRequested {} + } + + It 'streams assigned and unassigned canonical records with stable category ids' { + $records = @(Search-IntunePolicy -PolicySearchTerm Baseline -PassThru) + + $records.Count | Should -Be 2 + @($records.CategoryId | Sort-Object -Unique) | Should -Be @('DeviceConfigurations') + ($records | Where-Object PolicyId -eq assigned).AssignmentMode | Should -BeExactly Include + ($records | Where-Object PolicyId -eq empty).AssignmentMode | Should -BeExactly None + @($records | Where-Object { $_.PSObject.TypeNames[0] -ne 'IntuneAssignmentChecker.AssignmentRecord' }) | Should -BeNullOrEmpty + } +} diff --git a/Tests/Unit/TestGroupMembership.Tests.ps1 b/Tests/Unit/TestGroupMembership.Tests.ps1 index b7fb1f9..9aa7e83 100644 --- a/Tests/Unit/TestGroupMembership.Tests.ps1 +++ b/Tests/Unit/TestGroupMembership.Tests.ps1 @@ -15,6 +15,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Test-IntuneGroupMembership.ps1') diff --git a/Tests/Unit/TestGroupRemoval.Tests.ps1 b/Tests/Unit/TestGroupRemoval.Tests.ps1 index b84966c..8286d19 100644 --- a/Tests/Unit/TestGroupRemoval.Tests.ps1 +++ b/Tests/Unit/TestGroupRemoval.Tests.ps1 @@ -15,6 +15,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Test-IntuneGroupRemoval.ps1') diff --git a/Tests/Unit/UserAssignment.Tests.ps1 b/Tests/Unit/UserAssignment.Tests.ps1 index 5622337..105b537 100644 --- a/Tests/Unit/UserAssignment.Tests.ps1 +++ b/Tests/Unit/UserAssignment.Tests.ps1 @@ -16,6 +16,10 @@ BeforeAll { . (Join-Path $modulePrivate 'Get-AppProtectionAssignmentUri.ps1') . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $modulePrivate 'Get-IntuneCategoryDefinition.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Get-IntuneUserAssignment.ps1') @@ -186,6 +190,23 @@ Describe 'Get-IntuneUserAssignment' { } } + It 'streams only canonical records from PassThru with structured Graph metadata' { + $records = @(Get-IntuneUserAssignment -UserPrincipalNames 'user1@contoso.com' -PassThru) + + $records.Count | Should -BeGreaterThan 0 + @($records | Where-Object { $_.PSObject.TypeNames[0] -ne 'IntuneAssignmentChecker.AssignmentRecord' }) | Should -BeNullOrEmpty + $groupRecord = $records | Where-Object PolicyId -eq 'dc-mine' | Select-Object -First 1 + $groupRecord.CategoryId | Should -BeExactly DeviceConfigurations + $groupRecord.TargetType | Should -BeExactly Group + $groupRecord.TargetId | Should -BeExactly g-a + $groupRecord.FilterId | Should -BeExactly f1 + $appRecord = $records | Where-Object PolicyId -eq 'app-avail-inc' | Select-Object -First 1 + $appRecord.Intent | Should -BeExactly available + foreach ($record in $records) { + @($script:capturedExport | Where-Object { $_.Item -match "\(ID: $([regex]::Escape($record.PolicyId))\)$" }).Count | Should -BeGreaterThan 0 + } + } + It 'exports the User row first with the UPN and user id' { Get-IntuneUserAssignment -UserPrincipalNames 'user1@contoso.com' From 4cb2364adf9b89c92a9d8c8707a5597f8c14d5d4 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:23:23 +0200 Subject: [PATCH 3/8] Add Windows Update assignment coverage (#138) --- .../IntuneAssignmentChecker.psd1 | 1 + .../Private/ConvertTo-IACAssignmentRecord.ps1 | 2 +- .../Private/Get-IntuneAssignments.ps1 | 4 + .../Private/Get-IntuneCategoryDefinition.ps1 | 29 ++++++ .../Private/Get-IntuneEntities.ps1 | 10 +- .../Private/Invoke-IntuneCategoryScan.ps1 | 6 +- .../Public/Compare-IntuneGroupAssignment.ps1 | 4 + .../Public/Get-IntuneAllDevicesAssignment.ps1 | 8 +- .../Public/Get-IntuneAllPolicies.ps1 | 4 + .../Public/Get-IntuneAllUsersAssignment.ps1 | 17 +++- .../Public/Get-IntuneDeviceAssignment.ps1 | 10 +- .../Public/Get-IntuneGroupAssignment.ps1 | 7 +- .../Public/Get-IntuneUnassignedPolicy.ps1 | 47 +++++++++- .../Public/Get-IntuneUserAssignment.ps1 | 9 +- .../Public/Get-IntuneUserDeviceAssignment.ps1 | 28 ++++-- .../Public/Test-IntuneGroupMembership.ps1 | 1 + .../Public/Test-IntuneGroupRemoval.ps1 | 1 + .../IntuneAssignmentChecker/html-export.ps1 | 41 ++++++++- README.md | 7 +- Tests/Unit/CategoryScan.Tests.ps1 | 47 +++++++--- Tests/Unit/CompareGroupAssignment.Tests.ps1 | 13 ++- Tests/Unit/DeviceAssignment.Tests.ps1 | 6 +- Tests/Unit/GraphTransport.Tests.ps1 | 29 ++++++ Tests/Unit/GroupAssignment.Tests.ps1 | 2 +- Tests/Unit/HtmlReportCsv.Tests.ps1 | 32 ++++++- Tests/Unit/MobileAppScopeTags.Tests.ps1 | 23 ++++- Tests/Unit/SearchPassThru.Tests.ps1 | 2 +- Tests/Unit/TestGroupMembership.Tests.ps1 | 24 +++-- Tests/Unit/TestGroupRemoval.Tests.ps1 | 22 +++-- Tests/Unit/UserAssignment.Tests.ps1 | 21 ++++- Tests/Unit/WindowsUpdate.Tests.ps1 | 92 +++++++++++++++++++ 31 files changed, 484 insertions(+), 65 deletions(-) create mode 100644 Tests/Unit/WindowsUpdate.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index d19abed..bcfd8bf 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -46,6 +46,7 @@ ReleaseNotes = @' Version 4.4.0: - Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). +- Cover Windows Feature Update, Quality Update, Driver Update, and Quality Update policy assignments across shared scans, searches, comparisons, exports, and reports (issue #138). Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). diff --git a/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 index 66d4ad9..16bea75 100644 --- a/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 +++ b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACAssignmentRecord.ps1 @@ -55,7 +55,7 @@ function ConvertTo-IACAssignmentRecord { New-IACAssignmentRecord ` -CategoryId "$($Category.Id)" -Category "$categoryName" ` -PolicyId "$($Entity.id)" -PolicyName $policyName ` - -Platform (Get-PolicyPlatform -Policy $Entity) ` + -Platform $(if ($Category.Platform) { $Category.Platform } else { Get-PolicyPlatform -Policy $Entity }) ` -ScopeTagIds $scopeTagIds -ScopeTags $scopeTagNames ` -AssignmentId $Assignment.AssignmentId ` -AssignmentMode $assignmentMode ` diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 index fbd50d5..55f6fcc 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneAssignments.ps1 @@ -56,6 +56,10 @@ function Get-IntuneAssignments { # Imported Administrative Templates use the documented resource-path form. $actualAssignmentsUri = "$script:GraphEndpoint/beta/deviceManagement/groupPolicyConfigurations/$EntityId/assignments" } + elseif ($EntityType -in @('windowsFeatureUpdateProfiles', 'windowsQualityUpdateProfiles', 'windowsDriverUpdateProfiles', 'windowsQualityUpdatePolicies')) { + # Windows Update workloads expose assignments on the documented resource path. + $actualAssignmentsUri = "$script:GraphEndpoint/beta/deviceManagement/$EntityType/$EntityId/assignments" + } else { # General device management entities $actualAssignmentsUri = "$script:GraphEndpoint/beta/deviceManagement/$EntityType('$EntityId')/assignments" diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 index a7dc55c..0688c57 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 @@ -19,6 +19,7 @@ function Get-IntuneCategoryDefinition { BucketKeys = @() DisplayName = $null ExportCategory = $null + Platform = $null OptionalFeature = $false # BucketOnly categories register their BucketKeys for export parity but are never fetched. BucketOnly = $false @@ -71,6 +72,10 @@ function Get-IntuneCategoryDefinition { ESPProfiles = @{ Id = 'ESPProfiles'; EntityType = 'deviceEnrollmentConfigurations'; EntityFilter = $espEntityFilter; BucketKeys = @('ESPProfiles'); DisplayName = 'Enrollment Status Page Profiles'; ExportCategory = 'Enrollment Status Page' } CloudPCProvisioningPolicies = @{ Id = 'CloudPCProvisioningPolicies'; EntityType = 'virtualEndpoint/provisioningPolicies'; BucketKeys = @('CloudPCProvisioningPolicies'); DisplayName = 'Windows 365 Cloud PC Provisioning Policies'; ExportCategory = 'Windows 365 Cloud PC Provisioning Policy'; OptionalFeature = $true } CloudPCUserSettings = @{ Id = 'CloudPCUserSettings'; EntityType = 'virtualEndpoint/userSettings'; BucketKeys = @('CloudPCUserSettings'); DisplayName = 'Windows 365 Cloud PC User Settings'; ExportCategory = 'Windows 365 Cloud PC User Setting'; OptionalFeature = $true } + WindowsFeatureUpdates = @{ Id = 'WindowsFeatureUpdates'; EntityType = 'windowsFeatureUpdateProfiles'; BucketKeys = @('WindowsFeatureUpdates'); DisplayName = 'Windows Feature Update Profiles'; ExportCategory = 'Windows Feature Update Profile'; Platform = 'Windows'; OptionalFeature = $true } + WindowsQualityUpdates = @{ Id = 'WindowsQualityUpdates'; EntityType = 'windowsQualityUpdateProfiles'; BucketKeys = @('WindowsQualityUpdates'); DisplayName = 'Windows Quality Update Profiles'; ExportCategory = 'Windows Quality Update Profile'; Platform = 'Windows'; OptionalFeature = $true } + WindowsDriverUpdates = @{ Id = 'WindowsDriverUpdates'; EntityType = 'windowsDriverUpdateProfiles'; BucketKeys = @('WindowsDriverUpdates'); DisplayName = 'Windows Driver Update Profiles'; ExportCategory = 'Windows Driver Update Profile'; Platform = 'Windows'; OptionalFeature = $true } + WindowsQualityUpdatePolicies = @{ Id = 'WindowsQualityUpdatePolicies'; EntityType = 'windowsQualityUpdatePolicies'; BucketKeys = @('WindowsQualityUpdatePolicies'); DisplayName = 'Windows Quality Update Policies'; ExportCategory = 'Windows Quality Update Policy'; Platform = 'Windows'; OptionalFeature = $true } } $use = { @@ -123,6 +128,10 @@ function Get-IntuneCategoryDefinition { ) $categories += @(& $newEsCategories { param($family) "$($family.Name) Policies" }) $categories += @( + & $use 'WindowsFeatureUpdates' + & $use 'WindowsQualityUpdates' + & $use 'WindowsDriverUpdates' + & $use 'WindowsQualityUpdatePolicies' & $use 'CloudPCProvisioningPolicies' & $use 'CloudPCUserSettings' # Autopilot/ESP buckets exist for export parity but are not fetched for a user @@ -148,6 +157,10 @@ function Get-IntuneCategoryDefinition { & $use 'HealthScripts' & $use 'CloudPCProvisioningPolicies' & $use 'CloudPCUserSettings' + & $use 'WindowsFeatureUpdates' + & $use 'WindowsQualityUpdates' + & $use 'WindowsDriverUpdates' + & $use 'WindowsQualityUpdatePolicies' ) $categories += @(& $newEsCategories { param($family) "$($family.ShortName) Policies" }) $categories += @( @@ -176,6 +189,10 @@ function Get-IntuneCategoryDefinition { & $use 'ESPProfiles' & $use 'CloudPCProvisioningPolicies' & $use 'CloudPCUserSettings' + & $use 'WindowsFeatureUpdates' + & $use 'WindowsQualityUpdates' + & $use 'WindowsDriverUpdates' + & $use 'WindowsQualityUpdatePolicies' ) return $categories } @@ -195,6 +212,10 @@ function Get-IntuneCategoryDefinition { & $use 'ESPProfiles' & $use 'CloudPCProvisioningPolicies' & $use 'CloudPCUserSettings' + & $use 'WindowsFeatureUpdates' + & $use 'WindowsQualityUpdates' + & $use 'WindowsDriverUpdates' + & $use 'WindowsQualityUpdatePolicies' ) $categories += @(& $newEsCategories { param($family) "$($family.ShortName) Policies" }) return $categories @@ -220,6 +241,10 @@ function Get-IntuneCategoryDefinition { & $use 'ESPProfiles' $searchBucket & $use 'CloudPCProvisioningPolicies' ($searchBucket + @{ DisplayName = 'Cloud PC Provisioning Policies'; ExportCategory = 'Cloud PC Provisioning Policy' }) & $use 'CloudPCUserSettings' ($searchBucket + @{ DisplayName = 'Cloud PC User Settings'; ExportCategory = 'Cloud PC User Setting' }) + & $use 'WindowsFeatureUpdates' $searchBucket + & $use 'WindowsQualityUpdates' $searchBucket + & $use 'WindowsDriverUpdates' $searchBucket + & $use 'WindowsQualityUpdatePolicies' $searchBucket ) return $categories } @@ -239,6 +264,10 @@ function Get-IntuneCategoryDefinition { # (Compare-IntuneGroupAssignment.ps1:348-363); the migration must account for that shape. & $newCategory @{ Id = 'ShellScripts'; EntityType = 'deviceShellScripts'; BucketKeys = @('PlatformScripts'); DisplayName = 'Shell Scripts'; ExportCategory = 'Platform Scripts' } & $use 'HealthScripts' + & $use 'WindowsFeatureUpdates' @{ ExportCategory = 'Windows Feature Update Profiles' } + & $use 'WindowsQualityUpdates' @{ ExportCategory = 'Windows Quality Update Profiles' } + & $use 'WindowsDriverUpdates' @{ ExportCategory = 'Windows Driver Update Profiles' } + & $use 'WindowsQualityUpdatePolicies' @{ ExportCategory = 'Windows Quality Update Policies' } ) $categories += @(& $newEsCategories { param($family) $family.Export }) return $categories diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 index ba825fa..0c2939a 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 @@ -11,7 +11,12 @@ function Get-IntuneEntities { [string]$Select = "", [Parameter(Mandatory = $false)] - [string]$Expand = "" + [string]$Expand = "", + + # Optional beta workloads are not available in every tenant. Callers can + # suppress the expected warning without changing the empty-result contract. + [Parameter(Mandatory = $false)] + [switch]$Quiet ) # Handle special cases for app management and specific deviceManagement endpoints @@ -41,6 +46,9 @@ function Get-IntuneEntities { if ($statusCode -eq 403 -or $errorMessage -match "403|Forbidden|Authorization_RequestDenied") { Write-Warning "Permission denied (403) for '$EntityType'. Ensure admin consent has been granted for the required Graph API permissions. Run 'Connect-MgGraph -Scopes ...' with the necessary scopes or grant admin consent in Azure AD." } + elseif ($Quiet) { + Write-Verbose "Skipping unavailable entity set '$EntityType': $errorMessage" + } else { Write-Warning "Error fetching entities for ${EntityType}: $errorMessage" } diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index dd2747d..db1134d 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -52,9 +52,9 @@ function Invoke-IntuneCategoryScan { $records = [System.Collections.Generic.List[object]]::new() function Get-CachedEntitySet { - param([string]$EntityType) + param([string]$EntityType, [switch]$Quiet) if (-not $EntityCache.ContainsKey($EntityType)) { - $EntityCache[$EntityType] = @(Get-IntuneEntities -EntityType $EntityType) + $EntityCache[$EntityType] = @(Get-IntuneEntities -EntityType $EntityType -Quiet:$Quiet) } return , @($EntityCache[$EntityType]) } @@ -127,7 +127,7 @@ function Invoke-IntuneCategoryScan { try { switch ($category.Kind) { 'Entity' { - $entities = Get-CachedEntitySet -EntityType $category.EntityType + $entities = Get-CachedEntitySet -EntityType $category.EntityType -Quiet:$category.OptionalFeature if ($category.EntityFilter) { $entities = @($entities | Where-Object $category.EntityFilter) } diff --git a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 index 2df630e..d79c2cc 100644 --- a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 @@ -240,6 +240,10 @@ function Compare-IntuneGroupAssignment { "Uninstall Apps" = "UninstallApps" "Platform Scripts" = "PlatformScripts" "Proactive Remediation Scripts" = "HealthScripts" + "Windows Feature Update Profiles" = "WindowsFeatureUpdates" + "Windows Quality Update Profiles" = "WindowsQualityUpdates" + "Windows Driver Update Profiles" = "WindowsDriverUpdates" + "Windows Quality Update Policies" = "WindowsQualityUpdatePolicies" "Endpoint Security - Antivirus" = "AntivirusProfiles" "Endpoint Security - Disk Encryption" = "DiskEncryptionProfiles" "Endpoint Security - Firewall" = "FirewallProfiles" diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 index a487954..cdb542a 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllDevicesAssignment.ps1 @@ -42,6 +42,7 @@ function Get-IntuneAllDevicesAssignment { 'DeviceConfigurations', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'Applications', 'PlatformScripts', 'HealthScripts', 'DeploymentProfiles', 'ESPProfiles', + 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection')) { $categoryById[$id] } @@ -134,6 +135,10 @@ function Get-IntuneAllDevicesAssignment { @{ Bucket = 'AccountProtectionProfiles'; Header = 'Endpoint Security - Account Protection Profiles'; Empty = 'Account Protection Profiles'; Line = { param($item) "Account Protection Profile Name: $(Get-NameWithUnnamedFallback $item 'Unnamed Account Protection Profile'), Profile ID: $($item.id)" } } @{ Bucket = 'DeploymentProfiles'; Header = 'Autopilot Deployment Profiles'; Empty = 'Autopilot Deployment Profiles'; Line = { param($item) "Deployment Profile Name: $(Get-NamePreferringDisplayName $item), Profile ID: $($item.id)" } } @{ Bucket = 'ESPProfiles'; Header = 'Enrollment Status Page Profiles'; Empty = 'Enrollment Status Page Profiles'; Line = { param($item) "Enrollment Status Page Name: $(Get-NamePreferringDisplayName $item), Profile ID: $($item.id)" } } + @{ Bucket = 'WindowsFeatureUpdates'; Header = 'Windows Feature Update Profiles'; Empty = 'Windows Feature Update Profiles'; Line = { param($item) "Feature Update Profile Name: $(Get-NamePreferringDisplayName $item), Profile ID: $($item.id)" } } + @{ Bucket = 'WindowsQualityUpdates'; Header = 'Windows Quality Update Profiles'; Empty = 'Windows Quality Update Profiles'; Line = { param($item) "Quality Update Profile Name: $(Get-NamePreferringDisplayName $item), Profile ID: $($item.id)" } } + @{ Bucket = 'WindowsDriverUpdates'; Header = 'Windows Driver Update Profiles'; Empty = 'Windows Driver Update Profiles'; Line = { param($item) "Driver Update Profile Name: $(Get-NamePreferringDisplayName $item), Profile ID: $($item.id)" } } + @{ Bucket = 'WindowsQualityUpdatePolicies'; Header = 'Windows Quality Update Policies'; Empty = 'Windows Quality Update Policies'; Line = { param($item) "Quality Update Policy Name: $(Get-NamePreferringDisplayName $item), Policy ID: $($item.id)" } } ) foreach ($spec in $displaySpecs) { @@ -153,7 +158,8 @@ function Get-IntuneAllDevicesAssignment { 'DeviceConfigurations', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'Applications', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', - 'DeploymentProfiles', 'ESPProfiles')) { + 'DeploymentProfiles', 'ESPProfiles', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', + 'WindowsQualityUpdatePolicies')) { $categoryById[$id] } Add-CategoryExportData -ExportData $exportData -Categories $exportCategories -Buckets $allDevicesAssignments -AssignmentReason "All Devices" diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 index 83315c9..e20e4dc 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllPolicies.ps1 @@ -134,6 +134,10 @@ function Get-IntuneAllPolicies { Invoke-PolicyAssignments -Policies $allPolicies.ESPProfiles -DisplayName "Enrollment Status Page Profiles" Invoke-PolicyAssignments -Policies $allPolicies.CloudPCProvisioningPolicies -DisplayName "Windows 365 Cloud PC Provisioning Policies" Invoke-PolicyAssignments -Policies $allPolicies.CloudPCUserSettings -DisplayName "Windows 365 Cloud PC User Settings" + Invoke-PolicyAssignments -Policies $allPolicies.WindowsFeatureUpdates -DisplayName "Windows Feature Update Profiles" + Invoke-PolicyAssignments -Policies $allPolicies.WindowsQualityUpdates -DisplayName "Windows Quality Update Profiles" + Invoke-PolicyAssignments -Policies $allPolicies.WindowsDriverUpdates -DisplayName "Windows Driver Update Profiles" + Invoke-PolicyAssignments -Policies $allPolicies.WindowsQualityUpdatePolicies -DisplayName "Windows Quality Update Policies" Invoke-PolicyAssignments -Policies $allPolicies.AntivirusProfiles -DisplayName "Endpoint Security - Antivirus Profiles" Invoke-PolicyAssignments -Policies $allPolicies.DiskEncryptionProfiles -DisplayName "Endpoint Security - Disk Encryption Profiles" Invoke-PolicyAssignments -Policies $allPolicies.FirewallProfiles -DisplayName "Endpoint Security - Firewall Profiles" diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 index c86b027..784c929 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAllUsersAssignment.ps1 @@ -180,6 +180,20 @@ function Get-IntuneAllUsersAssignment { "Enrollment Status Page Profile Name: $profileName, Profile ID: $($policyProfile.id)" } + foreach ($updateSpec in @( + @{ Bucket = 'WindowsFeatureUpdates'; Header = 'Windows Feature Update Profiles'; Label = 'Feature Update Profile' } + @{ Bucket = 'WindowsQualityUpdates'; Header = 'Windows Quality Update Profiles'; Label = 'Quality Update Profile' } + @{ Bucket = 'WindowsDriverUpdates'; Header = 'Windows Driver Update Profiles'; Label = 'Driver Update Profile' } + @{ Bucket = 'WindowsQualityUpdatePolicies'; Header = 'Windows Quality Update Policies'; Label = 'Quality Update Policy' } + )) { + $label = $updateSpec.Label + Show-AllUsersSection -Header $updateSpec.Header -EmptyLabel $updateSpec.Header -Items $allUsersAssignments[$updateSpec.Bucket] -Line { + param($item) + $name = if ($item.displayName) { $item.displayName } else { $item.name } + "$label Name: $name, ID: $($item.id)" + } + } + # Add to export data. The legacy CSV row order follows the display order above, # where the app buckets came after the script categories, so export in that order # rather than fetch order. @@ -187,7 +201,8 @@ function Get-IntuneAllUsersAssignment { 'DeviceConfigurations', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'Applications', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', - 'ESAccountProtection', 'DeploymentProfiles', 'ESPProfiles' + 'ESAccountProtection', 'DeploymentProfiles', 'ESPProfiles', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', + 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies' ) $exportCategories = foreach ($id in $exportOrderIds) { $categories | Where-Object { $_.Id -eq $id } } Add-CategoryExportData -ExportData $exportData -Categories $exportCategories -Buckets $allUsersAssignments -AssignmentReason "All Users" diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 index 3823c9f..c5a4118 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneDeviceAssignment.ps1 @@ -44,7 +44,8 @@ function Get-IntuneDeviceAssignment { $categories = Get-IntuneCategoryDefinition -Audience 'DeviceContext' # Categories the legacy code fetched only for Windows (or unknown-OS) devices - $windowsOnlyCategoryIds = @('ImportedAdministrativeTemplates', 'DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings') + $windowsOnlyCategoryIds = @('ImportedAdministrativeTemplates', 'DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings', + 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies') # These legacy categories retain their historical first-match assignment walk. # Imported templates use standard exclusion precedence instead. $firstMatchCategoryIds = @('DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings') @@ -378,6 +379,10 @@ function Get-IntuneDeviceAssignment { @{ Title = 'Endpoint Security - EDR Profiles'; Bucket = 'EndpointDetectionProfiles'; GetName = $esProfileName } @{ Title = 'Endpoint Security - ASR Profiles'; Bucket = 'AttackSurfaceProfiles'; GetName = $esProfileName } @{ Title = 'Endpoint Security - Account Protection Profiles'; Bucket = 'AccountProtectionProfiles'; GetName = $esProfileName } + @{ Title = 'Windows Feature Update Profiles'; Bucket = 'WindowsFeatureUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Quality Update Profiles'; Bucket = 'WindowsQualityUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Driver Update Profiles'; Bucket = 'WindowsDriverUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Quality Update Policies'; Bucket = 'WindowsQualityUpdatePolicies'; GetName = $displayNameFirst } ) foreach ($section in $displaySections) { Format-PolicyTable -Title $section.Title -Policies @($relevantPolicies[$section.Bucket]) -GetName $section.GetName @@ -398,7 +403,8 @@ function Get-IntuneDeviceAssignment { @{ Ids = @('AppProtectionPolicies'); Reason = { param($item) $item.AssignmentSummary } } @{ Ids = @('AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'DeploymentProfiles', 'ESPProfiles', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', - 'CloudPCProvisioningPolicies', 'CloudPCUserSettings', 'Applications'); Reason = $reasonProperty } + 'CloudPCProvisioningPolicies', 'CloudPCUserSettings', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', + 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'Applications'); Reason = $reasonProperty } ) foreach ($batch in $exportBatches) { $batchCategories = foreach ($id in $batch.Ids) { $categories | Where-Object { $_.Id -eq $id } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 index 56172d0..b819f94 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneGroupAssignment.ps1 @@ -244,6 +244,10 @@ function Get-IntuneGroupAssignment { @{ Title = 'Endpoint Security - EDR Profiles'; Bucket = 'EndpointDetectionProfiles' } @{ Title = 'Endpoint Security - ASR Profiles'; Bucket = 'AttackSurfaceProfiles' } @{ Title = 'Endpoint Security - Account Protection Profiles'; Bucket = 'AccountProtectionProfiles' } + @{ Title = 'Windows Feature Update Profiles'; Bucket = 'WindowsFeatureUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Quality Update Profiles'; Bucket = 'WindowsQualityUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Driver Update Profiles'; Bucket = 'WindowsDriverUpdates'; GetName = $displayNameFirst } + @{ Title = 'Windows Quality Update Policies'; Bucket = 'WindowsQualityUpdatePolicies'; GetName = $displayNameFirst } ) foreach ($section in $displaySections) { $sectionParams = @{ @@ -278,7 +282,8 @@ function Get-IntuneGroupAssignment { @{ Ids = @('AppProtectionPolicies'); Reason = { param($item) $item.AssignmentSummary } } @{ Ids = @('AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', - 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'Applications'); Reason = $reasonProperty } + 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', + 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'Applications'); Reason = $reasonProperty } ) foreach ($batch in $exportBatches) { $batchCategories = foreach ($id in $batch.Ids) { $categories | Where-Object { $_.Id -eq $id } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 index c9d256d..d334f6a 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUnassignedPolicy.ps1 @@ -34,6 +34,10 @@ function Get-IntuneUnassignedPolicy { EndpointDetectionProfiles = @() AttackSurfaceProfiles = @() AccountProtectionProfiles = @() + WindowsFeatureUpdates = @() + WindowsQualityUpdates = @() + WindowsDriverUpdates = @() + WindowsQualityUpdatePolicies = @() Apps = @() } @@ -248,6 +252,21 @@ function Get-IntuneUnassignedPolicy { } } + # Get Windows Update policies. These optional beta workloads quietly return + # no entities when the tenant does not expose the corresponding feature. + foreach ($updateSpec in @( + @{ EntityType = 'windowsFeatureUpdateProfiles'; Bucket = 'WindowsFeatureUpdates'; Name = 'Windows Feature Update Profiles' } + @{ EntityType = 'windowsQualityUpdateProfiles'; Bucket = 'WindowsQualityUpdates'; Name = 'Windows Quality Update Profiles' } + @{ EntityType = 'windowsDriverUpdateProfiles'; Bucket = 'WindowsDriverUpdates'; Name = 'Windows Driver Update Profiles' } + @{ EntityType = 'windowsQualityUpdatePolicies'; Bucket = 'WindowsQualityUpdatePolicies'; Name = 'Windows Quality Update Policies' } + )) { + Write-Host "Fetching $($updateSpec.Name)..." -ForegroundColor Yellow + foreach ($policy in @(Get-IntuneEntities -EntityType $updateSpec.EntityType -Quiet)) { + $assignments = @(Get-IntuneAssignments -EntityType $updateSpec.EntityType -EntityId $policy.id) + if ($assignments.Count -eq 0) { $unassignedPolicies[$updateSpec.Bucket] += $policy } + } + } + # Get Unassigned Apps Write-Host "Fetching Unassigned Apps..." -ForegroundColor Yellow $unassignedAppUri = "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq false&`$select=id,displayName,roleScopeTagIds" @@ -383,6 +402,27 @@ function Get-IntuneUnassignedPolicy { } } + # Display Windows Update workloads + foreach ($updateSpec in @( + @{ Bucket = 'WindowsFeatureUpdates'; Header = 'Windows Feature Update Profiles'; Category = 'Windows Feature Update Profile' } + @{ Bucket = 'WindowsQualityUpdates'; Header = 'Windows Quality Update Profiles'; Category = 'Windows Quality Update Profile' } + @{ Bucket = 'WindowsDriverUpdates'; Header = 'Windows Driver Update Profiles'; Category = 'Windows Driver Update Profile' } + @{ Bucket = 'WindowsQualityUpdatePolicies'; Header = 'Windows Quality Update Policies'; Category = 'Windows Quality Update Policy' } + )) { + Write-Host "`n------- $($updateSpec.Header) -------" -ForegroundColor Cyan + $items = @($unassignedPolicies[$updateSpec.Bucket]) + if ($items.Count -eq 0) { + Write-Host "No unassigned $($updateSpec.Header) found" -ForegroundColor Gray + } + else { + foreach ($item in $items) { + $name = if ($item.displayName) { $item.displayName } else { $item.name } + Write-Host "$($updateSpec.Category) Name: $name, ID: $($item.id)" -ForegroundColor White + Add-ExportData -ExportData $exportData -Category $updateSpec.Category -Items @($item) -AssignmentReason 'No Assignment' + } + } + } + # Display Endpoint Security - Antivirus Profiles Write-Host "`n------- Endpoint Security - Antivirus Profiles -------" -ForegroundColor Cyan if ($unassignedPolicies.AntivirusProfiles.Count -eq 0) { @@ -486,6 +526,10 @@ function Get-IntuneUnassignedPolicy { EndpointDetectionProfiles = @('ESEndpointDetection', 'Endpoint Security - EDR') AttackSurfaceProfiles = @('ESAttackSurface', 'Endpoint Security - ASR') AccountProtectionProfiles = @('ESAccountProtection', 'Endpoint Security - Account Protection') + WindowsFeatureUpdates = @('WindowsFeatureUpdates', 'Windows Feature Update Profile', 'Windows') + WindowsQualityUpdates = @('WindowsQualityUpdates', 'Windows Quality Update Profile', 'Windows') + WindowsDriverUpdates = @('WindowsDriverUpdates', 'Windows Driver Update Profile', 'Windows') + WindowsQualityUpdatePolicies = @('WindowsQualityUpdatePolicies', 'Windows Quality Update Policy', 'Windows') Apps = @('Applications', 'Application') } foreach ($bucketName in $categoryMap.Keys) { @@ -495,8 +539,9 @@ function Get-IntuneUnassignedPolicy { elseif ($script:ScopeTagLookup) { @((Get-ScopeTagNames -ScopeTagIds $scopeTagIds -ScopeTagLookup $script:ScopeTagLookup) -split ', ') } else { @($scopeTagIds | ForEach-Object { "Tag:$_" }) } $policyName = if ($entity.displayName) { $entity.displayName } elseif ($entity.name) { $entity.name } else { 'Unnamed Policy' } + $platform = if ($categoryMap[$bucketName].Count -gt 2) { $categoryMap[$bucketName][2] } else { Get-PolicyPlatform -Policy $entity } New-IACAssignmentRecord -CategoryId $categoryMap[$bucketName][0] -Category $categoryMap[$bucketName][1] ` - -PolicyId "$($entity.id)" -PolicyName $policyName -Platform (Get-PolicyPlatform -Policy $entity) ` + -PolicyId "$($entity.id)" -PolicyName $policyName -Platform $platform ` -ScopeTagIds $scopeTagIds -ScopeTags $scopeTags -AssignmentMode None -TargetType None ` -SubjectType Tenant -SubjectName Unassigned -AssignmentReason 'No Assignment' -Source 'Get-IntuneUnassignedPolicy' } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 index 27d13ab..aa568be 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserAssignment.ps1 @@ -266,7 +266,7 @@ function Get-IntuneUserAssignment { Write-Host "`nAssignments for User: $upn" -ForegroundColor Green # Calculate category summary - $categoryNames = @('DeviceConfigs', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'AppsRequired', 'AppsAvailable', 'AppsUninstall', 'AntivirusProfiles', 'DiskEncryptionProfiles', 'FirewallProfiles', 'EndpointDetectionProfiles', 'AttackSurfaceProfiles', 'AccountProtectionProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings') + $categoryNames = @('DeviceConfigs', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'AppsRequired', 'AppsAvailable', 'AppsUninstall', 'AntivirusProfiles', 'DiskEncryptionProfiles', 'FirewallProfiles', 'EndpointDetectionProfiles', 'AttackSurfaceProfiles', 'AccountProtectionProfiles', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings') $nonEmptyCount = ($categoryNames | Where-Object { $relevantPolicies[$_].Count -gt 0 }).Count $totalDisplayCategories = $categoryNames.Count Write-Host "`nFound assignments in $nonEmptyCount of $totalDisplayCategories categories." -ForegroundColor Cyan @@ -351,6 +351,10 @@ function Get-IntuneUserAssignment { @{ Title = 'Endpoint Security - Endpoint Detection and Response Profiles'; Bucket = 'EndpointDetectionProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } @{ Title = 'Endpoint Security - Attack Surface Reduction Profiles'; Bucket = 'AttackSurfaceProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } @{ Title = 'Endpoint Security - Account Protection Profiles'; Bucket = 'AccountProtectionProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } + @{ Title = 'Windows Feature Update Profiles'; Bucket = 'WindowsFeatureUpdates'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } + @{ Title = 'Windows Quality Update Profiles'; Bucket = 'WindowsQualityUpdates'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } + @{ Title = 'Windows Driver Update Profiles'; Bucket = 'WindowsDriverUpdates'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst } + @{ Title = 'Windows Quality Update Policies'; Bucket = 'WindowsQualityUpdatePolicies'; NameLabel = 'Policy Name'; IdLabel = 'Policy ID'; GetName = $policyNameFirst } @{ Title = 'Windows 365 Cloud PC Provisioning Policies'; Bucket = 'CloudPCProvisioningPolicies'; NameLabel = 'Policy Name'; IdLabel = 'Policy ID'; GetName = $policyNameFirst } @{ Title = 'Windows 365 Cloud PC User Settings'; Bucket = 'CloudPCUserSettings'; NameLabel = 'Setting Name'; IdLabel = 'Setting ID'; GetName = $settingNameFirst } ) @@ -381,7 +385,8 @@ function Get-IntuneUserAssignment { @{ Ids = @('AppProtectionPolicies'); Reason = { param($item) $item.AssignmentSummary } } @{ Ids = @('AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', - 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'Applications'); Reason = $reasonProperty } + 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'WindowsFeatureUpdates', 'WindowsQualityUpdates', + 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'Applications'); Reason = $reasonProperty } ) foreach ($batch in $exportBatches) { $batchCategories = foreach ($id in $batch.Ids) { $categories | Where-Object { $_.Id -eq $id } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 index 43dd66a..a71b015 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 @@ -166,13 +166,17 @@ function Get-IntuneUserDeviceAssignment { ESPProfiles = [System.Collections.ArrayList]::new() CloudPCProvisioningPolicies = [System.Collections.ArrayList]::new() CloudPCUserSettings = [System.Collections.ArrayList]::new() + WindowsFeatureUpdates = [System.Collections.ArrayList]::new() + WindowsQualityUpdates = [System.Collections.ArrayList]::new() + WindowsDriverUpdates = [System.Collections.ArrayList]::new() + WindowsQualityUpdatePolicies = [System.Collections.ArrayList]::new() } # Helper: standard fetch -> resolve -> classify pattern for generic categories $processGeneric = { - param($entityType, $bucketKey, [switch]$SkipPlatformCheck) + param($entityType, $bucketKey, [switch]$SkipPlatformCheck, [switch]$Quiet) - $items = Get-IntuneEntities -EntityType $entityType + $items = Get-IntuneEntities -EntityType $entityType -Quiet:$Quiet foreach ($item in $items) { $assignments = Get-IntuneAssignments -EntityType $entityType -EntityId $item.id $reason = Resolve-AssignmentReason -Assignments $assignments -GroupMembershipIds $combinedGroupIds -IncludeReasons $includeReasons @@ -311,12 +315,16 @@ function Get-IntuneUserDeviceAssignment { [void]$relevantPolicies.ESPProfiles.Add($esp) } - Write-Host " Cloud PC Provisioning / User Settings..." -ForegroundColor Yellow + Write-Host " Cloud PC and Windows Update policies..." -ForegroundColor Yellow try { - & $processGeneric "virtualEndpoint/provisioningPolicies" "CloudPCProvisioningPolicies" -SkipPlatformCheck - & $processGeneric "virtualEndpoint/userSettings" "CloudPCUserSettings" -SkipPlatformCheck + & $processGeneric "virtualEndpoint/provisioningPolicies" "CloudPCProvisioningPolicies" -SkipPlatformCheck -Quiet + & $processGeneric "virtualEndpoint/userSettings" "CloudPCUserSettings" -SkipPlatformCheck -Quiet + & $processGeneric "windowsFeatureUpdateProfiles" "WindowsFeatureUpdates" -SkipPlatformCheck -Quiet + & $processGeneric "windowsQualityUpdateProfiles" "WindowsQualityUpdates" -SkipPlatformCheck -Quiet + & $processGeneric "windowsDriverUpdateProfiles" "WindowsDriverUpdates" -SkipPlatformCheck -Quiet + & $processGeneric "windowsQualityUpdatePolicies" "WindowsQualityUpdatePolicies" -SkipPlatformCheck -Quiet } - catch { Write-Verbose "Skipping - Windows 365 may not be licensed for this tenant" } + catch { Write-Verbose "Skipping an optional Cloud PC or Windows Update workload: $($_.Exception.Message)" } } # โ”€โ”€ App Protection (per-platform endpoints, user-targeted only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -513,6 +521,10 @@ function Get-IntuneUserDeviceAssignment { ESPProfiles = "Enrollment Status Page Profiles" CloudPCProvisioningPolicies = "Cloud PC Provisioning" CloudPCUserSettings = "Cloud PC User Settings" + WindowsFeatureUpdates = "Windows Feature Update Profiles" + WindowsQualityUpdates = "Windows Quality Update Profiles" + WindowsDriverUpdates = "Windows Driver Update Profiles" + WindowsQualityUpdatePolicies = "Windows Quality Update Policies" } $totalEffective = 0 @@ -585,6 +597,10 @@ function Get-IntuneUserDeviceAssignment { Add-ExportData -ExportData $exportData -Category "Enrollment Status Page Profile" -Items $relevantPolicies.ESPProfiles -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } Add-ExportData -ExportData $exportData -Category "Cloud PC Provisioning Policy" -Items $relevantPolicies.CloudPCProvisioningPolicies -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } Add-ExportData -ExportData $exportData -Category "Cloud PC User Setting" -Items $relevantPolicies.CloudPCUserSettings -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } + Add-ExportData -ExportData $exportData -Category "Windows Feature Update Profile" -Items $relevantPolicies.WindowsFeatureUpdates -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } + Add-ExportData -ExportData $exportData -Category "Windows Quality Update Profile" -Items $relevantPolicies.WindowsQualityUpdates -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } + Add-ExportData -ExportData $exportData -Category "Windows Driver Update Profile" -Items $relevantPolicies.WindowsDriverUpdates -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } + Add-ExportData -ExportData $exportData -Category "Windows Quality Update Policy" -Items $relevantPolicies.WindowsQualityUpdatePolicies -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUserDeviceAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode } diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 index cde049b..574f776 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 @@ -179,6 +179,7 @@ function Test-IntuneGroupMembership { @('DeviceConfigurations', 'ImportedAdministrativeTemplates', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'Applications', 'PlatformScripts', 'HealthScripts', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'DeploymentProfiles', 'ESPProfiles', + 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings') | ForEach-Object { $categoriesById[$_] }) # Legacy conflict-row category labels: registry export labels except these five diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 index 11db464..20115cb 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 @@ -215,6 +215,7 @@ function Test-IntuneGroupRemoval { 'AppConfigurationPolicies', 'Applications', 'PlatformScripts', 'HealthScripts', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'DeploymentProfiles', 'ESPProfiles', + 'WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings')) { $categoryIndex[$id].BucketOnly = $false $categoryIndex[$id] diff --git a/Module/IntuneAssignmentChecker/html-export.ps1 b/Module/IntuneAssignmentChecker/html-export.ps1 index 2137f10..bff648c 100644 --- a/Module/IntuneAssignmentChecker/html-export.ps1 +++ b/Module/IntuneAssignmentChecker/html-export.ps1 @@ -633,6 +633,10 @@ function Export-HTMLReport { AccountProtectionProfiles = @() CloudPCProvisioningPolicies = @() CloudPCUserSettings = @() + WindowsFeatureUpdates = @() + WindowsQualityUpdates = @() + WindowsDriverUpdates = @() + WindowsQualityUpdatePolicies = @() } # Fetch all policies @@ -859,6 +863,31 @@ function Export-HTMLReport { Write-Warning "Unable to fetch Windows 365 Cloud PC User Settings: $($_.Exception.Message)" } + # Windows Update for Business reports. Each workload is optional so a tenant + # that lacks one feature cannot hide unrelated report categories. + foreach ($updateSpec in @( + @{ EntityType = 'windowsFeatureUpdateProfiles'; Key = 'WindowsFeatureUpdates'; Type = 'Windows Feature Update Profile'; Label = 'Windows Feature Update Profiles' } + @{ EntityType = 'windowsQualityUpdateProfiles'; Key = 'WindowsQualityUpdates'; Type = 'Windows Quality Update Profile'; Label = 'Windows Quality Update Profiles' } + @{ EntityType = 'windowsDriverUpdateProfiles'; Key = 'WindowsDriverUpdates'; Type = 'Windows Driver Update Profile'; Label = 'Windows Driver Update Profiles' } + @{ EntityType = 'windowsQualityUpdatePolicies'; Key = 'WindowsQualityUpdatePolicies'; Type = 'Windows Quality Update Policy'; Label = 'Windows Quality Update Policies' } + )) { + Write-Host "Fetching $($updateSpec.Label)..." -ForegroundColor Yellow + foreach ($policy in @(Get-IntuneEntities -EntityType $updateSpec.EntityType -Quiet)) { + $assignments = Get-IntuneAssignments -EntityType $updateSpec.EntityType -EntityId $policy.id + $assignmentInfo = Get-HtmlAssignmentInfo -Assignments $assignments + $policies[$updateSpec.Key] += @{ + Name = if ([string]::IsNullOrWhiteSpace($policy.displayName)) { $policy.name } else { $policy.displayName } + ID = $policy.id + Type = $updateSpec.Type + Platform = 'Windows' + ScopeTags = Get-ScopeTagNames -ScopeTagIds $policy.roleScopeTagIds -ScopeTagLookup $script:ScopeTagLookup + AssignmentType = $assignmentInfo.Type + AssignedTo = $assignmentInfo.Target + Filter = $assignmentInfo.Filter + } + } + } + # Endpoint Security Policies Fetching $endpointSecurityCategories = @( @{ Name = "Antivirus"; Key = "AntivirusProfiles"; TemplateFamily = "endpointSecurityAntivirus"; UserFriendlyType = "Antivirus Profile" }, @@ -1032,6 +1061,10 @@ function Export-HTMLReport { @{ Key = 'ESPProfiles'; Name = 'Enrollment Status Page Profiles' }, @{ Key = 'CloudPCProvisioningPolicies'; Name = 'Windows 365 Cloud PC Provisioning Policies' }, @{ Key = 'CloudPCUserSettings'; Name = 'Windows 365 Cloud PC User Settings' }, + @{ Key = 'WindowsFeatureUpdates'; Name = 'Windows Feature Update Profiles' }, + @{ Key = 'WindowsQualityUpdates'; Name = 'Windows Quality Update Profiles' }, + @{ Key = 'WindowsDriverUpdates'; Name = 'Windows Driver Update Profiles' }, + @{ Key = 'WindowsQualityUpdatePolicies'; Name = 'Windows Quality Update Policies' }, @{ Key = 'AntivirusProfiles'; Name = 'Endpoint Security - Antivirus' }, @{ Key = 'DiskEncryptionProfiles'; Name = 'Endpoint Security - Disk Encryption' }, @{ Key = 'FirewallProfiles'; Name = 'Endpoint Security - Firewall' }, @@ -1283,7 +1316,7 @@ function Export-HTMLReport { var policyTypesChart = new Chart(ctx2, { type: 'bar', data: { - labels: ['Device Configs', 'Imported Admin Templates', 'Settings Catalog', 'Compliance', 'App Protection', 'Autopilot Profiles', 'ESP Profiles', 'Windows 365 Provisioning', 'Windows 365 User Settings', 'Scripts', 'Antivirus', 'Disk Encryption', 'Firewall', 'EDR', 'ASR', 'Account Protection'], + labels: ['Device Configs', 'Imported Admin Templates', 'Settings Catalog', 'Compliance', 'App Protection', 'Autopilot Profiles', 'ESP Profiles', 'Windows 365 Provisioning', 'Windows 365 User Settings', 'Feature Updates', 'Quality Profiles', 'Driver Updates', 'Quality Policies', 'Scripts', 'Antivirus', 'Disk Encryption', 'Firewall', 'EDR', 'ASR', 'Account Protection'], datasets: [{ label: 'Number of Policies', data: [ @@ -1296,6 +1329,10 @@ function Export-HTMLReport { $($policies.ESPProfiles.Count), $($policies.CloudPCProvisioningPolicies.Count), $($policies.CloudPCUserSettings.Count), + $($policies.WindowsFeatureUpdates.Count), + $($policies.WindowsQualityUpdates.Count), + $($policies.WindowsDriverUpdates.Count), + $($policies.WindowsQualityUpdatePolicies.Count), ($($policies.PlatformScripts.Count) + $($policies.HealthScripts.Count)), $($policies.AntivirusProfiles.Count), $($policies.DiskEncryptionProfiles.Count), @@ -1306,7 +1343,7 @@ function Export-HTMLReport { ], backgroundColor: [ '#4e73df', '#1cc88a', '#36b9cc', '#f6c23e', '#e74a3b', '#6f42c1', '#20c997', - '#17a2b8', '#fd7e14', '#858796', '#5a5c69', '#f8f9fc', '#dddfeb', '#d1d3e2', '#b4b6c2', '#6610f2' + '#17a2b8', '#fd7e14', '#0d6efd', '#198754', '#ffc107', '#dc3545', '#858796', '#5a5c69', '#f8f9fc', '#dddfeb', '#d1d3e2', '#b4b6c2', '#6610f2' ] }] }, diff --git a/README.md b/README.md index 788aa98..155c066 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ IntuneAssignmentChecker - ๐Ÿ”„ Version check on connect with an update notice when a newer PSGallery release is available - ๐Ÿ“Š Detailed reporting of Configuration Profiles, Compliance Policies, and Applications - ๐Ÿงฉ Imported Administrative Template coverage across assignment checks, search, CSV exports, and HTML reports +- ๐Ÿ”„ Windows Update for Business coverage for Feature Update, Quality Update, and Driver Update profiles plus Quality Update policies - ๐Ÿ‘ฅ First-class Microsoft 365 group recognition with group type, membership mode, and mail address in group assignment checks and exports - ๐Ÿ“ˆ Interactive HTML reports with charts and filterable tables @@ -406,9 +407,9 @@ shared-scan cmdlets create canonical records from the same structured Graph data used for their console and CSV views, while the HTML report keeps its purpose-built flat reporting schema. Treat `CategoryId` as the stable machine key; `Category` is a presentation label and can vary where a cmdlet distinguishes app intents or uses -search-specific wording. `Get-IntuneUserDeviceAssignment` intentionally keeps its -legacy output in this release slice; the v4.4 effective-targeting cmdlet introduced -in issue #140 provides canonical user/device results. +search-specific wording. `Get-IntuneUserDeviceAssignment` keeps its established +combined user/device presentation; the v4.4 effective-targeting cmdlet introduced +in issue #140 adds a canonical explanation model for those results. `Get-IntuneGroupAssignment` CSV/Excel exports include `GroupId`, `GroupName`, `GroupType`, `MembershipType`, and `GroupMail` on every group and policy/app diff --git a/Tests/Unit/CategoryScan.Tests.ps1 b/Tests/Unit/CategoryScan.Tests.ps1 index 470b58a..a509e35 100644 --- a/Tests/Unit/CategoryScan.Tests.ps1 +++ b/Tests/Unit/CategoryScan.Tests.ps1 @@ -20,7 +20,7 @@ BeforeAll { # Stub collaborators so Pester can mock them per test function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -519,6 +519,10 @@ Describe 'Add-CategoryExportData' { 'Enrollment Status Page' 'Windows 365 Cloud PC Provisioning Policy' 'Windows 365 Cloud PC User Setting' + 'Windows Feature Update Profile' + 'Windows Quality Update Profile' + 'Windows Driver Update Profile' + 'Windows Quality Update Policy' 'Endpoint Security - Antivirus' 'Endpoint Security - Disk Encryption' 'Endpoint Security - Firewall' @@ -542,46 +546,63 @@ Describe 'Add-CategoryExportData' { } Describe 'Get-IntuneCategoryDefinition' { - It 'returns 17 fetchable categories plus Autopilot/ESP bucket placeholders for UserContext' { + It 'returns 21 fetchable categories plus Autopilot/ESP bucket placeholders for UserContext' { $categories = Get-IntuneCategoryDefinition -Audience UserContext - @($categories | Where-Object { -not $_.BucketOnly }).Count | Should -Be 17 + @($categories | Where-Object { -not $_.BucketOnly }).Count | Should -Be 21 @($categories | Where-Object { $_.BucketOnly }).Id | Should -Be @('DeploymentProfiles', 'ESPProfiles') } - It 'returns 17 fetchable categories plus Autopilot/ESP bucket placeholders for DeviceContext' { + It 'returns 21 fetchable categories plus Autopilot/ESP bucket placeholders for DeviceContext' { $categories = Get-IntuneCategoryDefinition -Audience DeviceContext - @($categories | Where-Object { -not $_.BucketOnly }).Count | Should -Be 17 + @($categories | Where-Object { -not $_.BucketOnly }).Count | Should -Be 21 @($categories | Where-Object { $_.BucketOnly }).Id | Should -Be @('DeploymentProfiles', 'ESPProfiles') } - It 'returns 19 categories including Imported Administrative Templates, Autopilot and ESP for GroupContext' { + It 'returns 23 categories including Windows Update, Imported Administrative Templates, Autopilot and ESP for GroupContext' { $categories = Get-IntuneCategoryDefinition -Audience GroupContext - @($categories).Count | Should -Be 19 + @($categories).Count | Should -Be 23 @($categories | Where-Object { $_.BucketOnly }).Count | Should -Be 0 $categories.Id | Should -Contain 'DeploymentProfiles' $categories.Id | Should -Contain 'ESPProfiles' } - It 'returns 18 categories without Applications for AllPolicies' { + It 'returns 22 categories without Applications for AllPolicies' { $categories = Get-IntuneCategoryDefinition -Audience AllPolicies - @($categories).Count | Should -Be 18 + @($categories).Count | Should -Be 22 $categories.Id | Should -Not -Contain 'Applications' } - It 'returns 19 categories with a shared SearchResults bucket for Search' { + It 'returns 23 categories with a shared SearchResults bucket for Search' { $categories = Get-IntuneCategoryDefinition -Audience Search - @($categories).Count | Should -Be 19 + @($categories).Count | Should -Be 23 foreach ($category in $categories) { $category.BucketKeys | Should -Be @('SearchResults') } } - It 'returns 14 categories for Compare' { + It 'returns 18 categories for Compare' { $categories = Get-IntuneCategoryDefinition -Audience Compare - @($categories).Count | Should -Be 14 + @($categories).Count | Should -Be 18 $categories.Id | Should -Contain 'ShellScripts' } + It 'registers every Windows Update workload as an optional shared entity category' { + $expected = [ordered]@{ + WindowsFeatureUpdates = 'windowsFeatureUpdateProfiles' + WindowsQualityUpdates = 'windowsQualityUpdateProfiles' + WindowsDriverUpdates = 'windowsDriverUpdateProfiles' + WindowsQualityUpdatePolicies = 'windowsQualityUpdatePolicies' + } + foreach ($audience in @('UserContext', 'DeviceContext', 'GroupContext', 'AllPolicies', 'Search', 'Compare')) { + $categories = Get-IntuneCategoryDefinition -Audience $audience + foreach ($id in $expected.Keys) { + $category = $categories | Where-Object Id -eq $id + $category.EntityType | Should -BeExactly $expected[$id] + $category.OptionalFeature | Should -BeTrue + } + } + } + It 'includes custom and mixed imported templates but excludes built-in-only configurations' { $category = @(Get-IntuneCategoryDefinition -Audience GroupContext | Where-Object { $_.Id -eq 'ImportedAdministrativeTemplates' })[0] $policies = @( diff --git a/Tests/Unit/CompareGroupAssignment.Tests.ps1 b/Tests/Unit/CompareGroupAssignment.Tests.ps1 index 4e37472..feb6db1 100644 --- a/Tests/Unit/CompareGroupAssignment.Tests.ps1 +++ b/Tests/Unit/CompareGroupAssignment.Tests.ps1 @@ -22,7 +22,7 @@ BeforeAll { # Stub collaborators so Pester can mock them per test function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -92,6 +92,9 @@ Describe 'Compare-IntuneGroupAssignment' { [PSCustomObject]@{ id = 'intent-av'; displayName = 'AV Intent Policy'; templateId = 'tmpl-av'; templateReference = [PSCustomObject]@{ templateFamily = 'endpointSecurityAntivirus' } } ) } + 'windowsFeatureUpdateProfiles' { + @([PSCustomObject]@{ id = 'feature-1'; displayName = 'Windows 11 24H2'; roleScopeTagIds = @('0') }) + } default { @() } } } @@ -113,6 +116,7 @@ Describe 'Compare-IntuneGroupAssignment' { 'ps-2' { @([PSCustomObject]@{ Reason = 'Direct Exclusion'; GroupId = $script:groupA; FilterId = $null; FilterType = $null }) } 'hs-1' { @([PSCustomObject]@{ Reason = 'Direct Assignment'; GroupId = $script:groupA; FilterId = $null; FilterType = $null }) } 'hs-2' { @([PSCustomObject]@{ Reason = 'Direct Exclusion'; GroupId = $script:groupA; FilterId = $null; FilterType = $null }) } + 'feature-1' { @([PSCustomObject]@{ Reason = 'Direct Assignment'; GroupId = $script:groupA; FilterId = $null; FilterType = $null }) } default { @() } } # Mirror the real helper: only assignments targeting the requested group ids @@ -288,6 +292,13 @@ Describe 'Compare-IntuneGroupAssignment' { $cpRows[0].'Group A' | Should -BeExactly 'Included' } + It 'includes Windows Update policies in the comparison matrix' { + $row = $script:rows | Where-Object { $_.PolicyName -eq 'Windows 11 24H2' } + $row.Category | Should -BeExactly 'Windows Feature Update Profiles' + $row.'Group A' | Should -BeExactly 'Included' + $row.'Group B' | Should -BeExactly '' + } + It 'never fetches assignments for ES configurationPolicies policies (prefilter)' { # escp-1 assignments are fetched once per group by the Settings Catalog # category; the Endpoint Security phase 1 must not add extra fetches diff --git a/Tests/Unit/DeviceAssignment.Tests.ps1 b/Tests/Unit/DeviceAssignment.Tests.ps1 index 7f741d6..757a2d6 100644 --- a/Tests/Unit/DeviceAssignment.Tests.ps1 +++ b/Tests/Unit/DeviceAssignment.Tests.ps1 @@ -31,7 +31,7 @@ BeforeAll { # Stub collaborators so Pester can mock them per test function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -104,7 +104,9 @@ Describe 'Get-IntuneDeviceAssignment' { 'Required Apps', 'Available Apps', 'Uninstall Apps', 'Endpoint Security - Antivirus Profiles', 'Endpoint Security - Disk Encryption Profiles', 'Endpoint Security - Firewall Profiles', 'Endpoint Security - EDR Profiles', - 'Endpoint Security - ASR Profiles', 'Endpoint Security - Account Protection Profiles') + 'Endpoint Security - ASR Profiles', 'Endpoint Security - Account Protection Profiles', + 'Windows Feature Update Profiles', 'Windows Quality Update Profiles', + 'Windows Driver Update Profiles', 'Windows Quality Update Policies') $script:consoleLines | Should -Contain 'No Device Configurations found for this device.' } diff --git a/Tests/Unit/GraphTransport.Tests.ps1 b/Tests/Unit/GraphTransport.Tests.ps1 index 407ea1c..d04e9b6 100644 --- a/Tests/Unit/GraphTransport.Tests.ps1 +++ b/Tests/Unit/GraphTransport.Tests.ps1 @@ -13,6 +13,35 @@ BeforeAll { } . (Join-Path $moduleRoot 'Private/Invoke-IACGraphRequest.ps1') + . (Join-Path $moduleRoot 'Private/Get-IntuneEntities.ps1') +} + +Describe 'Get-IntuneEntities optional workload diagnostics' { + BeforeEach { + $script:GraphEndpoint = 'https://graph.test' + Mock Write-Warning {} + } + + It 'still warns for permission failures when Quiet is requested' { + Mock Invoke-IACGraphRequest { + $exception = [System.Exception]::new('HTTP 403 Forbidden') + $exception.Data['StatusCode'] = 403 + $exception.Data['GraphErrorCode'] = 'Authorization_RequestDenied' + throw $exception + } + + @(Get-IntuneEntities -EntityType 'windowsFeatureUpdateProfiles' -Quiet) | Should -BeNullOrEmpty + + Should -Invoke Write-Warning -Exactly 1 -ParameterFilter { $Message -like "Permission denied (403)*" } + } + + It 'quietly returns an empty result for an unavailable optional workload' { + Mock Invoke-IACGraphRequest { throw 'HTTP 400 Bad Request' } + + @(Get-IntuneEntities -EntityType 'windowsFeatureUpdateProfiles' -Quiet) | Should -BeNullOrEmpty + + Should -Invoke Write-Warning -Exactly 0 + } } Describe 'Invoke-IACGraphRequest' { diff --git a/Tests/Unit/GroupAssignment.Tests.ps1 b/Tests/Unit/GroupAssignment.Tests.ps1 index c389f83..36276c1 100644 --- a/Tests/Unit/GroupAssignment.Tests.ps1 +++ b/Tests/Unit/GroupAssignment.Tests.ps1 @@ -30,7 +30,7 @@ BeforeAll { # Stub collaborators so Pester can mock them per test function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { diff --git a/Tests/Unit/HtmlReportCsv.Tests.ps1 b/Tests/Unit/HtmlReportCsv.Tests.ps1 index d447589..039bffc 100644 --- a/Tests/Unit/HtmlReportCsv.Tests.ps1 +++ b/Tests/Unit/HtmlReportCsv.Tests.ps1 @@ -13,7 +13,7 @@ BeforeAll { $script:GraphEndpoint = 'https://graph.test' - function Get-IntuneEntities { param([string]$EntityType) @() } + function Get-IntuneEntities { param([string]$EntityType, [switch]$Quiet) @() } function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId) @() } function Get-AppProtectionAssignmentUri { param($Policy) $null } function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } @@ -80,6 +80,36 @@ Describe 'HTML report CSV companion' { $rows[0].Filter | Should -BeExactly 'None' } + It 'includes Windows Update policies in the HTML companion CSV' { + Mock Get-IntuneEntities { + if ($EntityType -eq 'windowsFeatureUpdateProfiles') { + return @([PSCustomObject]@{ + id = 'feature-1' + displayName = 'Windows 11 24H2' + roleScopeTagIds = @('0') + }) + } + @() + } + Mock Get-IntuneAssignments { + if ($EntityId -eq 'feature-1') { + return @([PSCustomObject]@{ Reason = 'All Devices'; GroupId = $null; FilterId = $null; FilterType = $null }) + } + @() + } + $htmlPath = Join-Path $TestDrive 'updates/report.html' + $csvPath = Join-Path $TestDrive 'updates/report.csv' + New-Item -ItemType Directory -Path (Split-Path $htmlPath -Parent) -Force | Out-Null + + Export-HTMLReport -FilePath $htmlPath -CSVReportPath $csvPath + + $row = Import-Csv -Path $csvPath | Where-Object ID -eq 'feature-1' + $row.Category | Should -BeExactly 'Windows Feature Update Profiles' + $row.Name | Should -BeExactly 'Windows 11 24H2' + $row.Platform | Should -BeExactly Windows + $row.AssignmentType | Should -BeExactly 'All Devices' + } + It 'uses the HTML base path for the CSV companion by default' { $htmlPath = Join-Path $TestDrive 'default/report.html' $expectedCsvPath = Join-Path $TestDrive 'default/report.csv' diff --git a/Tests/Unit/MobileAppScopeTags.Tests.ps1 b/Tests/Unit/MobileAppScopeTags.Tests.ps1 index 1dab0b7..b7eaae5 100644 --- a/Tests/Unit/MobileAppScopeTags.Tests.ps1 +++ b/Tests/Unit/MobileAppScopeTags.Tests.ps1 @@ -12,7 +12,7 @@ BeforeAll { . (Join-Path $modulePrivate 'Test-ImportedAdministrativeTemplate.ps1') . (Join-Path $moduleRoot 'Public/Get-IntuneUnassignedPolicy.ps1') - function Get-IntuneEntities { param([string]$EntityType) @() } + function Get-IntuneEntities { param([string]$EntityType, [switch]$Quiet) @() } function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId) @() } function Get-AppProtectionAssignmentUri { param($Policy) $null } function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } @@ -97,6 +97,27 @@ Describe 'Mobile application scope tags' { $records[0].ScopeTagIds | Should -Be @('0', 'tag-finance') } + It 'returns Windows platform metadata for unassigned Windows Update records' { + Mock Get-IntuneEntities { + if ($EntityType -eq 'windowsFeatureUpdateProfiles') { + return @([PSCustomObject]@{ + id = 'feature-unassigned' + displayName = 'Windows 11 24H2' + roleScopeTagIds = @('0') + }) + } + @() + } + + $records = @(Get-IntuneUnassignedPolicy -PassThru -ErrorAction Stop) + $record = $records | Where-Object PolicyId -eq 'feature-unassigned' + + $record.CategoryId | Should -BeExactly WindowsFeatureUpdates + $record.Category | Should -BeExactly 'Windows Feature Update Profile' + $record.Platform | Should -BeExactly Windows + $record.AssignmentMode | Should -BeExactly None + } + It 'exports unassigned custom and mixed imported templates but never queries built-in-only assignments' { Mock Get-IntuneEntities { if ($EntityType -eq 'groupPolicyConfigurations') { diff --git a/Tests/Unit/SearchPassThru.Tests.ps1 b/Tests/Unit/SearchPassThru.Tests.ps1 index c20c4de..271898f 100644 --- a/Tests/Unit/SearchPassThru.Tests.ps1 +++ b/Tests/Unit/SearchPassThru.Tests.ps1 @@ -17,7 +17,7 @@ BeforeAll { $script:GraphEndpoint = 'https://graph.test' $script:ScopeTagLookup = @{} $script:AssignmentFilterLookup = @{} - function Get-IntuneEntities { param([string]$EntityType) @() } + function Get-IntuneEntities { param([string]$EntityType, [switch]$Quiet) @() } function Get-IntuneAssignments { param([string]$EntityType, [string]$EntityId, [string[]]$GroupIds = @()) @() } function Add-IntentTemplateFamilyInfo { param($IntentPolicies) } function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } diff --git a/Tests/Unit/TestGroupMembership.Tests.ps1 b/Tests/Unit/TestGroupMembership.Tests.ps1 index 9aa7e83..c82d34e 100644 --- a/Tests/Unit/TestGroupMembership.Tests.ps1 +++ b/Tests/Unit/TestGroupMembership.Tests.ps1 @@ -48,7 +48,7 @@ BeforeAll { @() } function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -295,17 +295,19 @@ Describe 'Test-IntuneGroupMembership' { Should -Invoke Invoke-IACGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*mobileApps?*isAssigned*' } } - It 'emits the 19-step progress lines including Imported Administrative Templates' { + It 'emits the 23-step progress lines including Windows Update workloads' { Test-IntuneGroupMembership -UserPrincipalNames 'user1@contoso.com' -SimulateTargetGroup 'Target Group' - $script:hostLines | Should -Contain '[1/19] Fetching Device Configurations...' - $script:hostLines | Should -Contain '[2/19] Fetching Imported Administrative Templates...' - $script:hostLines | Should -Contain '[7/19] Fetching Applications...' - $script:hostLines | Should -Contain '[10/19] Fetching Antivirus Policies...' - $script:hostLines | Should -Contain '[13/19] Fetching Endpoint Detection and Response Policies...' - $script:hostLines | Should -Contain '[16/19] Fetching Autopilot Deployment Profiles...' - $script:hostLines | Should -Contain '[17/19] Fetching Enrollment Status Page Profiles...' - $script:hostLines | Should -Contain '[19/19] Fetching Windows 365 Cloud PC User Settings...' + $script:hostLines | Should -Contain '[1/23] Fetching Device Configurations...' + $script:hostLines | Should -Contain '[2/23] Fetching Imported Administrative Templates...' + $script:hostLines | Should -Contain '[7/23] Fetching Applications...' + $script:hostLines | Should -Contain '[10/23] Fetching Antivirus Policies...' + $script:hostLines | Should -Contain '[13/23] Fetching Endpoint Detection and Response Policies...' + $script:hostLines | Should -Contain '[16/23] Fetching Autopilot Deployment Profiles...' + $script:hostLines | Should -Contain '[17/23] Fetching Enrollment Status Page Profiles...' + $script:hostLines | Should -Contain '[18/23] Fetching Windows Feature Update Profiles...' + $script:hostLines | Should -Contain '[21/23] Fetching Windows Quality Update Policies...' + $script:hostLines | Should -Contain '[23/23] Fetching Windows 365 Cloud PC User Settings...' } It 'escapes single quotes in the group name OData filter (F9)' { @@ -327,6 +329,8 @@ Describe 'Test-IntuneGroupMembership' { 'NEW: Endpoint Security - Firewall', 'NEW: Endpoint Security - EDR', 'NEW: Endpoint Security - ASR', 'NEW: Endpoint Security - Account Protection', 'NEW: Autopilot Deployment Profile', 'NEW: Enrollment Status Page Profile', + 'NEW: Windows Feature Update Profile', 'NEW: Windows Quality Update Profile', + 'NEW: Windows Driver Update Profile', 'NEW: Windows Quality Update Policy', 'NEW: Cloud PC Provisioning Policy', 'NEW: Cloud PC User Setting', 'CONFLICT: Device Configuration', 'CONFLICT: Application (required)' ) diff --git a/Tests/Unit/TestGroupRemoval.Tests.ps1 b/Tests/Unit/TestGroupRemoval.Tests.ps1 index 8286d19..c7199e6 100644 --- a/Tests/Unit/TestGroupRemoval.Tests.ps1 +++ b/Tests/Unit/TestGroupRemoval.Tests.ps1 @@ -62,7 +62,7 @@ BeforeAll { @() } function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -271,15 +271,17 @@ Describe 'Test-IntuneGroupRemoval' { $row.Item | Should -Be 'AP AllDev (ID: ap-alldev)' } - It 'walks the 19 categories including Imported Administrative Templates' { - $headers = @($script:hostLines | Where-Object { $_ -match '^\[\d+/19\] Fetching ' }) - $headers | Should -HaveCount 19 - $headers[0] | Should -Be '[1/19] Fetching Device Configurations...' - $headers[1] | Should -Be '[2/19] Fetching Imported Administrative Templates...' - $headers[6] | Should -Be '[7/19] Fetching Applications...' - $headers[9] | Should -Be '[10/19] Fetching Antivirus Policies...' - $headers[15] | Should -Be '[16/19] Fetching Autopilot Deployment Profiles...' - $headers[18] | Should -Be '[19/19] Fetching Windows 365 Cloud PC User Settings...' + It 'walks the 23 categories including Windows Update workloads' { + $headers = @($script:hostLines | Where-Object { $_ -match '^\[\d+/23\] Fetching ' }) + $headers | Should -HaveCount 23 + $headers[0] | Should -Be '[1/23] Fetching Device Configurations...' + $headers[1] | Should -Be '[2/23] Fetching Imported Administrative Templates...' + $headers[6] | Should -Be '[7/23] Fetching Applications...' + $headers[9] | Should -Be '[10/23] Fetching Antivirus Policies...' + $headers[15] | Should -Be '[16/23] Fetching Autopilot Deployment Profiles...' + $headers[17] | Should -Be '[18/23] Fetching Windows Feature Update Profiles...' + $headers[20] | Should -Be '[21/23] Fetching Windows Quality Update Policies...' + $headers[22] | Should -Be '[23/23] Fetching Windows 365 Cloud PC User Settings...' } It 'skips unlicensed Windows 365 categories without failing the run' { diff --git a/Tests/Unit/UserAssignment.Tests.ps1 b/Tests/Unit/UserAssignment.Tests.ps1 index 105b537..e9e70fd 100644 --- a/Tests/Unit/UserAssignment.Tests.ps1 +++ b/Tests/Unit/UserAssignment.Tests.ps1 @@ -37,7 +37,7 @@ BeforeAll { @() } function Get-IntuneEntities { - param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand) + param([string]$EntityType, [string]$Filter, [string]$Select, [string]$Expand, [switch]$Quiet) @() } function Get-IntuneAssignments { @@ -116,6 +116,9 @@ Describe 'Get-IntuneUserAssignment' { 'deviceManagement/intents' { @([PSCustomObject]@{ id = 'av-intent'; displayName = 'AV Intent Legacy' }) } + 'windowsFeatureUpdateProfiles' { + @([PSCustomObject]@{ id = 'feature-excl'; displayName = 'Feature Update Excluded'; roleScopeTagIds = @('0') }) + } default { @() } } } @@ -131,6 +134,12 @@ Describe 'Get-IntuneUserAssignment' { ) } 'av-cfg' { @([PSCustomObject]@{ Reason = 'All Users'; GroupId = $null; FilterId = $null; FilterType = $null }) } + 'feature-excl' { + @( + [PSCustomObject]@{ Reason = 'All Users'; GroupId = $null; FilterId = $null; FilterType = $null } + [PSCustomObject]@{ Reason = 'Group Exclusion'; GroupId = 'g-a'; FilterId = $null; FilterType = $null } + ) + } default { @() } } } @@ -226,6 +235,14 @@ Describe 'Get-IntuneUserAssignment' { @($configRows | Where-Object { $_.Item -like '*dc-other*' }).Count | Should -Be 0 } + It 'honors exclusion precedence for Windows Update policies' { + Get-IntuneUserAssignment -UserPrincipalNames 'user1@contoso.com' + + $row = $script:capturedExport | Where-Object { $_.Item -eq 'Feature Update Excluded (ID: feature-excl)' } + $row.Category | Should -BeExactly 'Windows Feature Update Profile' + $row.AssignmentReason | Should -BeExactly Excluded + } + It 'keeps excluded apps visible in the excluding assignment intent bucket with filter suffix' { Get-IntuneUserAssignment -UserPrincipalNames 'user1@contoso.com' @@ -296,6 +313,8 @@ Describe 'Get-IntuneUserAssignment' { 'Windows 365 Cloud PC Provisioning Policy', 'Windows 365 Cloud PC User Setting', 'Endpoint Security - Antivirus', 'Endpoint Security - Disk Encryption', 'Endpoint Security - Firewall', 'Endpoint Security - EDR', 'Endpoint Security - ASR', 'Endpoint Security - Account Protection', + 'Windows Feature Update Profile', 'Windows Quality Update Profile', 'Windows Driver Update Profile', + 'Windows Quality Update Policy', 'Required Apps', 'Available Apps', 'Uninstall Apps' ) $actualOrder = @($script:capturedExport.Category | Select-Object -Unique) diff --git a/Tests/Unit/WindowsUpdate.Tests.ps1 b/Tests/Unit/WindowsUpdate.Tests.ps1 new file mode 100644 index 0000000..0f802f1 --- /dev/null +++ b/Tests/Unit/WindowsUpdate.Tests.ps1 @@ -0,0 +1,92 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $private = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Private' + . (Join-Path $private 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $private 'Get-IntuneAssignments.ps1') + $script:GraphEndpoint = 'https://graph.test' + function Invoke-IACGraphRequest { param($Uri, $Method) @{ value = @() } } +} + +Describe 'Windows Update assignment coverage' { + BeforeEach { + Mock Write-Warning {} + Mock Invoke-IACGraphRequest { + @{ + value = @( + [PSCustomObject]@{ + id = 'assignment-include' + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = 'group-1' + deviceAndAppManagementAssignmentFilterId = 'filter-1' + deviceAndAppManagementAssignmentFilterType = 'include' + } + } + [PSCustomObject]@{ + id = 'assignment-exclude' + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget' + groupId = 'group-2' + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + [PSCustomObject]@{ + id = 'assignment-all-devices' + target = [PSCustomObject]@{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } + } + ) + } + } + } + + It 'uses the live-verified resource-path assignments endpoint for every workload' -ForEach @( + @{ EntityType = 'windowsFeatureUpdateProfiles' } + @{ EntityType = 'windowsQualityUpdateProfiles' } + @{ EntityType = 'windowsDriverUpdateProfiles' } + @{ EntityType = 'windowsQualityUpdatePolicies' } + ) { + $assignments = @(Get-IntuneAssignments -EntityType $EntityType -EntityId 'profile-1') + + $assignments | Should -HaveCount 3 + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq "https://graph.test/beta/deviceManagement/$EntityType/profile-1/assignments" -and $Method -eq 'Get' + } + } + + It 'normalizes aggregated assignment responses for groups, exclusions, filters, and All Devices' { + $assignments = @(Get-IntuneAssignments -EntityType windowsFeatureUpdateProfiles -EntityId profile-1) + + $assignments.Reason | Should -Be @('Group Assignment', 'Group Exclusion', 'All Devices') + $assignments.AssignmentMode | Should -Be @('Include', 'Exclude', 'Include') + $assignments.TargetType | Should -Be @('Group', 'Group', 'AllDevices') + $assignments[0].FilterId | Should -BeExactly filter-1 + $assignments[0].FilterType | Should -BeExactly include + } + + It 'preserves group-specific filtering semantics' { + $assignments = @(Get-IntuneAssignments -EntityType windowsFeatureUpdateProfiles -EntityId profile-1 -GroupIds @('group-2')) + $assignments | Should -HaveCount 1 + $assignments[0].Reason | Should -BeExactly 'Direct Exclusion' + } + + It 'wires every workload through all required presentation and export surfaces' { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $paths = @( + 'Public/Get-IntuneUserAssignment.ps1', 'Public/Get-IntuneGroupAssignment.ps1', + 'Public/Get-IntuneDeviceAssignment.ps1', 'Public/Get-IntuneAllPolicies.ps1', + 'Public/Get-IntuneAllUsersAssignment.ps1', 'Public/Get-IntuneAllDevicesAssignment.ps1', + 'Public/Get-IntuneUnassignedPolicy.ps1', 'Public/Get-IntuneUserDeviceAssignment.ps1', + 'Public/Compare-IntuneGroupAssignment.ps1', 'Public/Test-IntuneGroupMembership.ps1', + 'Public/Test-IntuneGroupRemoval.ps1', 'html-export.ps1' + ) + foreach ($path in $paths) { + $source = Get-Content -Raw (Join-Path $moduleRoot $path) + foreach ($bucket in @('WindowsFeatureUpdates', 'WindowsQualityUpdates', 'WindowsDriverUpdates', 'WindowsQualityUpdatePolicies')) { + $source | Should -Match ([regex]::Escape($bucket)) + } + } + } +} From eeda2307fbe4588b2c9bfa09e2d85c865538f2b2 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:51:30 +0200 Subject: [PATCH 4/8] Add safe assignment filter evaluation (#139) --- .../IntuneAssignmentChecker.psd1 | 2 + .../Private/Get-AssignmentFilterLookup.ps1 | 9 +- .../Private/Get-IACManagedDevice.ps1 | 42 ++ .../Private/Test-IACAssignmentFilter.ps1 | 590 ++++++++++++++++++ .../Public/Test-IntuneAssignmentFilter.ps1 | 92 +++ README.md | 16 + .../Unit/AssignmentFilterEvaluation.Tests.ps1 | 398 ++++++++++++ 7 files changed, 1146 insertions(+), 3 deletions(-) create mode 100644 Module/IntuneAssignmentChecker/Private/Get-IACManagedDevice.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Test-IACAssignmentFilter.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilter.ps1 create mode 100644 Tests/Unit/AssignmentFilterEvaluation.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index bcfd8bf..8457698 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -25,6 +25,7 @@ 'Get-IntuneFailedAssignment' 'Test-IntuneGroupMembership' 'Test-IntuneGroupRemoval' + 'Test-IntuneAssignmentFilter' 'Search-IntunePolicy' 'Search-IntuneSetting' 'Update-IntuneSettingDefinition' @@ -47,6 +48,7 @@ Version 4.4.0: - Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). - Cover Windows Feature Update, Quality Update, Driver Update, and Quality Update policy assignments across shared scans, searches, comparisons, exports, and reports (issue #138). +- Add Test-IntuneAssignmentFilter for safe, local tri-state evaluation of documented managed-device filter rules without executing tenant-provided text (issue #139). Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). diff --git a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 index 4bec10f..7197b5d 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-AssignmentFilterLookup.ps1 @@ -3,11 +3,14 @@ function Get-AssignmentFilterLookup { param() $lookup = @{} try { - $uri = "$script:GraphEndpoint/beta/deviceManagement/assignmentFilters?`$select=id,displayName,platform" + $uri = "$script:GraphEndpoint/beta/deviceManagement/assignmentFilters?`$select=id,displayName,platform,rule,assignmentFilterManagementType" foreach ($filter in @((Invoke-IACGraphRequest -Uri $uri -Method Get).value)) { $lookup["$($filter.id)"] = [PSCustomObject]@{ - Name = $filter.displayName - Platform = $filter.platform + Id = $filter.id + Name = $filter.displayName + Platform = $filter.platform + Rule = $filter.rule + AssignmentFilterManagementType = $filter.assignmentFilterManagementType } } } diff --git a/Module/IntuneAssignmentChecker/Private/Get-IACManagedDevice.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IACManagedDevice.ps1 new file mode 100644 index 0000000..5f33511 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Get-IACManagedDevice.ps1 @@ -0,0 +1,42 @@ +function Get-IACManagedDevice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Identity + ) + + # These are the beta managedDevice fields used by the documented Intune + # assignment-filter property map. skuNumber is used instead of the coarse + # skuFamily, and deviceOwnership is intentionally not a managedDevice field. + $select = @( + 'id', 'deviceName', 'operatingSystem', 'osVersion', 'model', 'manufacturer', + 'managedDeviceOwnerType', 'enrollmentProfileName', 'skuNumber', + 'deviceCategoryDisplayName', 'azureADDeviceId', 'jailBroken', + 'processorArchitecture', 'joinType', 'deviceEnrollmentType', 'managementAgent' + ) -join ',' + + try { + if ($Identity -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { + $device = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/managedDevices/$Identity`?`$select=$select" -Method Get + if ($device -and $device.id) { + return [PSCustomObject]@{ Success = $true; Device = $device; MultipleFound = $false; Reason = $null } + } + return [PSCustomObject]@{ Success = $false; Device = $null; MultipleFound = $false; Reason = "Managed device '$Identity' was not found." } + } + + $escapedName = $Identity -replace "'", "''" + $encodedFilter = [uri]::EscapeDataString("deviceName eq '$escapedName'") + $response = Invoke-IACGraphRequest -Uri "$script:GraphEndpoint/beta/deviceManagement/managedDevices?`$filter=$encodedFilter&`$select=$select" -Method Get + $devices = @($response.value) + if ($devices.Count -eq 1) { + return [PSCustomObject]@{ Success = $true; Device = $devices[0]; MultipleFound = $false; Reason = $null } + } + if ($devices.Count -gt 1) { + return [PSCustomObject]@{ Success = $false; Device = $null; MultipleFound = $true; Reason = "Multiple managed devices are named '$Identity'; use the Intune managed-device ID." } + } + return [PSCustomObject]@{ Success = $false; Device = $null; MultipleFound = $false; Reason = "Managed device '$Identity' was not found." } + } + catch { + return [PSCustomObject]@{ Success = $false; Device = $null; MultipleFound = $false; Reason = "Managed-device lookup failed: $($_.Exception.Message)" } + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Test-IACAssignmentFilter.ps1 b/Module/IntuneAssignmentChecker/Private/Test-IACAssignmentFilter.ps1 new file mode 100644 index 0000000..7d22ea3 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Test-IACAssignmentFilter.ps1 @@ -0,0 +1,590 @@ +function ConvertTo-IACTokenList { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Rule + ) + + if ($Rule.Length -gt 8192) { throw 'Assignment filter rule exceeds the 8192-character safety limit.' } + + $tokens = [System.Collections.Generic.List[object]]::new() + $index = 0 + while ($index -lt $Rule.Length) { + if ($tokens.Count -ge 1024) { throw 'Assignment filter rule exceeds the 1024-token safety limit.' } + $character = $Rule[$index] + if ([char]::IsWhiteSpace($character)) { + $index++ + continue + } + + $punctuation = switch ($character) { + '(' { 'LeftParen' } + ')' { 'RightParen' } + '[' { 'LeftBracket' } + ']' { 'RightBracket' } + ',' { 'Comma' } + default { $null } + } + if ($punctuation) { + $tokens.Add([PSCustomObject]@{ Kind = $punctuation; Value = "$character"; Position = $index }) + $index++ + continue + } + + if ($character -eq '"' -or $character -eq "'") { + $quote = $character + $start = $index + $index++ + $builder = [System.Text.StringBuilder]::new() + $closed = $false + while ($index -lt $Rule.Length) { + $current = $Rule[$index] + if ($current -eq $quote) { + # Accept doubled quotes as a literal quote as well as the common + # backslash/backtick escape form handled below. + if (($index + 1) -lt $Rule.Length -and $Rule[$index + 1] -eq $quote) { + [void]$builder.Append($quote) + $index += 2 + continue + } + $closed = $true + $index++ + break + } + if (($current -eq '\' -or $current -eq '`') -and ($index + 1) -lt $Rule.Length) { + $escaped = $Rule[$index + 1] + if ($escaped -eq $quote -or $escaped -eq '\' -or $escaped -eq '`') { + [void]$builder.Append($escaped) + $index += 2 + continue + } + } + [void]$builder.Append($current) + $index++ + } + if (-not $closed) { throw "Unterminated string literal at position $start." } + $tokens.Add([PSCustomObject]@{ Kind = 'String'; Value = $builder.ToString(); Position = $start }) + continue + } + + if ("$character" -match '^[A-Za-z0-9_.$-]$') { + $start = $index + while ($index -lt $Rule.Length -and "$($Rule[$index])" -match '^[A-Za-z0-9_.$-]$') { $index++ } + $tokens.Add([PSCustomObject]@{ Kind = 'Word'; Value = $Rule.Substring($start, $index - $start); Position = $start }) + continue + } + + throw "Unsupported character '$character' at position $index." + } + + $tokens.Add([PSCustomObject]@{ Kind = 'End'; Value = ''; Position = $Rule.Length }) + return , $tokens +} + +function ConvertTo-IACFilterAst { + [CmdletBinding()] + # PSScriptAnalyzer cannot trace the Tokens reference through the nested + # recursive-descent parser functions below. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Tokens')] + param( + [Parameter(Mandatory)] + [object[]]$Tokens + ) + + $state = [PSCustomObject]@{ Position = 0; Depth = 0 } + $comparisonOperators = @('eq', 'ne', 'gt', 'ge', 'lt', 'le', 'in', 'notin', 'contains', 'notcontains', 'startswith') + + function Get-IACParserToken { + return $Tokens[$state.Position] + } + + function Read-IACParserToken { + param([string]$Kind) + $token = Get-IACParserToken + if ($token.Kind -ne $Kind) { throw "Expected $Kind at position $($token.Position), found $($token.Kind)." } + $state.Position++ + return $token + } + + function Get-IACNormalizedOperator { + param([string]$Value) + $normalized = $Value.TrimStart([char]'-').ToLowerInvariant() + switch ($normalized) { + 'equals' { 'eq' } + 'notequals' { 'ne' } + 'greaterthan' { 'gt' } + 'greaterthanorequals' { 'ge' } + 'lessthan' { 'lt' } + 'lessthanorequals' { 'le' } + default { $normalized } + } + } + + function Test-IACLogicalToken { + param([string]$Name) + $token = Get-IACParserToken + return $token.Kind -eq 'Word' -and (Get-IACNormalizedOperator -Value $token.Value) -eq $Name + } + + function Read-IACScalarLiteral { + $token = Get-IACParserToken + if ($token.Kind -eq 'String') { + $state.Position++ + return [PSCustomObject]@{ ValueType = 'String'; Value = $token.Value } + } + if ($token.Kind -eq 'Word') { + $state.Position++ + if ($token.Value -ieq 'null' -or $token.Value -ieq '$null') { + return [PSCustomObject]@{ ValueType = 'Null'; Value = $null } + } + return [PSCustomObject]@{ ValueType = 'Bare'; Value = $token.Value } + } + throw "Expected a scalar value at position $($token.Position)." + } + + function Read-IACLiteral { + if ((Get-IACParserToken).Kind -ne 'LeftBracket') { return Read-IACScalarLiteral } + + [void](Read-IACParserToken -Kind 'LeftBracket') + $values = [System.Collections.Generic.List[object]]::new() + if ((Get-IACParserToken).Kind -ne 'RightBracket') { + $values.Add((Read-IACScalarLiteral)) + while ((Get-IACParserToken).Kind -eq 'Comma') { + [void](Read-IACParserToken -Kind 'Comma') + $values.Add((Read-IACScalarLiteral)) + } + } + [void](Read-IACParserToken -Kind 'RightBracket') + return [PSCustomObject]@{ ValueType = 'Array'; Value = @($values) } + } + + function Read-IACComparison { + $property = Read-IACParserToken -Kind 'Word' + if ($property.Value -notmatch '^(?i:device|app)\.[A-Za-z][A-Za-z0-9_]*$') { + throw "Invalid assignment-filter property '$($property.Value)' at position $($property.Position)." + } + $operatorToken = Read-IACParserToken -Kind 'Word' + $operator = Get-IACNormalizedOperator -Value $operatorToken.Value + if ($comparisonOperators -notcontains $operator) { + throw "Unsupported assignment-filter operator '$($operatorToken.Value)' at position $($operatorToken.Position)." + } + $literal = Read-IACLiteral + return [PSCustomObject]@{ + NodeType = 'Comparison' + Property = $property.Value + Operator = $operator + Literal = $literal + } + } + + function Read-IACPrimary { + if ((Get-IACParserToken).Kind -eq 'LeftParen') { + $state.Depth++ + if ($state.Depth -gt 64) { throw 'Assignment filter nesting exceeds the 64-level safety limit.' } + [void](Read-IACParserToken -Kind 'LeftParen') + $expression = Read-IACOrExpression + [void](Read-IACParserToken -Kind 'RightParen') + $state.Depth-- + return $expression + } + return Read-IACComparison + } + + function Read-IACAndExpression { + $left = Read-IACPrimary + while (Test-IACLogicalToken -Name 'and') { + $state.Position++ + $left = [PSCustomObject]@{ NodeType = 'And'; Left = $left; Right = (Read-IACPrimary) } + } + return $left + } + + function Read-IACOrExpression { + $left = Read-IACAndExpression + while (Test-IACLogicalToken -Name 'or') { + $state.Position++ + $left = [PSCustomObject]@{ NodeType = 'Or'; Left = $left; Right = (Read-IACAndExpression) } + } + return $left + } + + $ast = Read-IACOrExpression + $remaining = Get-IACParserToken + if ($remaining.Kind -ne 'End') { throw "Unexpected token '$($remaining.Value)' at position $($remaining.Position)." } + return $ast +} + +function Get-IACObjectProperty { + param($InputObject, [string[]]$Names) + + foreach ($name in $Names) { + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + if ("$key" -ieq $name) { + return [PSCustomObject]@{ Found = $true; Name = "$key"; Value = $InputObject[$key] } + } + } + } + elseif ($null -ne $InputObject) { + $property = $InputObject.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 + if ($property) { return [PSCustomObject]@{ Found = $true; Name = $property.Name; Value = $property.Value } } + } + } + return [PSCustomObject]@{ Found = $false; Name = $null; Value = $null } +} + +function Get-IACDeviceFilterValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Device, + [Parameter(Mandatory)][string]$Property + ) + + # Intune filter property -> beta managedDevice/Entra device field mapping. + # Only these documented properties are eligible for local evaluation. + $propertyMap = @{ + cpuarchitecture = @{ Sources = @('processorArchitecture', 'cpuArchitecture'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin') } + devicecategory = @{ Sources = @('deviceCategoryDisplayName', 'deviceCategory'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + devicemanagementtype = @{ Sources = @('deviceManagementType', 'deviceEnrollmentType'); Transform = 'ManagementType'; ValueType = 'String'; Operators = @('eq', 'ne') } + devicename = @{ Sources = @('deviceName', 'displayName'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + deviceownership = @{ Sources = @('managedDeviceOwnerType', 'deviceOwnership'); Transform = 'Ownership'; ValueType = 'String'; Operators = @('eq', 'ne') } + devicetrusttype = @{ Sources = @('joinType', 'trustType'); Transform = 'TrustType'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin') } + enrollmentprofilename = @{ Sources = @('enrollmentProfileName'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + isrooted = @{ Sources = @('isRooted', 'jailBroken'); Transform = 'Rooted'; ValueType = 'String'; Operators = @('eq', 'ne') } + manufacturer = @{ Sources = @('manufacturer'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + model = @{ Sources = @('model'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + operatingsystemversion = @{ Sources = @('operatingSystemVersion', 'osVersion'); Transform = 'Identity'; ValueType = 'Version'; Operators = @('eq', 'ne', 'gt', 'ge', 'lt', 'le') } + osversion = @{ Sources = @('osVersion', 'operatingSystemVersion'); Transform = 'Identity'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + operatingsystemsku = @{ Sources = @('operatingSystemSKU', 'skuNumber'); Transform = 'Sku'; ValueType = 'String'; Operators = @('eq', 'ne', 'in', 'notin', 'startswith', 'contains', 'notcontains') } + } + + if ($Property -notmatch '^(?i:device)\.(?[A-Za-z][A-Za-z0-9_]*)$') { + return [PSCustomObject]@{ Known = $false; Reason = "Only documented managed-device properties are supported; '$Property' is not one." } + } + $propertyName = $Matches.name.ToLowerInvariant() + if (-not $propertyMap.ContainsKey($propertyName)) { + return [PSCustomObject]@{ Known = $false; Reason = "Unsupported managed-device filter property '$Property'." } + } + + $definition = $propertyMap[$propertyName] + $source = Get-IACObjectProperty -InputObject $Device -Names $definition.Sources + if (-not $source.Found) { + return [PSCustomObject]@{ Known = $false; Reason = "Device response does not contain a source field for '$Property'." } + } + if ($null -eq $source.Value) { + return [PSCustomObject]@{ Known = $true; Value = $null; ValueType = $definition.ValueType; Operators = $definition.Operators; SourceProperty = $source.Name } + } + + $rawValue = "$($source.Value)" + $transformed = switch ($definition.Transform) { + 'Ownership' { + switch -Regex ($rawValue) { + '^(?i:company|corporate)$' { 'Corporate'; break } + '^(?i:personal)$' { 'Personal'; break } + '^(?i:unknown)$' { 'Unknown'; break } + default { $null } + } + } + 'TrustType' { + switch -Regex ($rawValue) { + '^(?i:azuread|azureadjoined)$' { 'Azure AD joined'; break } + '^(?i:workplace|azureadregistered)$' { 'Azure AD registered'; break } + '^(?i:serverad|hybridazureadjoined)$' { 'Hybrid Azure AD joined'; break } + '^(?i:unknown)$' { 'Unknown'; break } + default { $null } + } + } + 'Rooted' { + if ($source.Value -is [bool]) { if ($source.Value) { 'True' } else { 'False' } } + elseif ($rawValue -match '^(?i:true|false|unknown)$') { $rawValue } + else { $null } + } + 'ManagementType' { + $managementTypes = @{ + androidenterprisededicateddevice = $null # shared mode cannot be inferred safely + androidenterprisefullymanaged = 'Corporate-owned fully managed' + androidenterprisecorporateworkprofile = 'Corporate-owned with work profile' + androidaospuserlessdeviceenrollment = 'AOSP userless devices' + androidaospuserowneddeviceenrollment = 'AOSP user-associated devices' + } + if ($managementTypes.ContainsKey($rawValue.ToLowerInvariant())) { $managementTypes[$rawValue.ToLowerInvariant()] } + elseif ($rawValue -in $managementTypes.Values) { $rawValue } + else { $null } + } + 'Sku' { + $skuByNumber = @{ + '4' = 'Enterprise'; '10' = 'Core'; '27' = 'EnterpriseN'; '48' = 'Professional'; '49' = 'BusinessN' + '72' = 'EnterpriseEval'; '84' = 'EnterpriseNEval'; '98' = 'CoreN'; '99' = 'CoreCountrySpecific' + '100' = 'CoreSingleLanguage'; '101' = 'Core'; '111' = 'Core'; '119' = 'PPIPro'; '121' = 'Education' + '122' = 'EducationN'; '123' = 'IoTUAP'; '125' = 'EnterpriseS'; '126' = 'EnterpriseSN' + '129' = 'EnterpriseSEval'; '131' = 'IoTUAPCommercial'; '136' = 'Holographic' + '138' = 'ProfessionalSingleLanguage'; '161' = 'ProfessionalWorkstation'; '162' = 'ProfessionalN' + '164' = 'ProfessionalEducation'; '165' = 'ProfessionalEducationN'; '171' = 'EnterpriseG' + '172' = 'EnterpriseGN'; '175' = 'ServerRdsh'; '188' = 'IoTEnterprise'; '202' = 'CloudEditionN' + '203' = 'CloudEdition' + } + $supportedSkus = @($skuByNumber.Values | Select-Object -Unique) + if ($source.Name -ieq 'skuNumber') { $skuByNumber[$rawValue] } + else { $supportedSkus | Where-Object { $_ -ieq $rawValue } | Select-Object -First 1 } + } + default { $source.Value } + } + + if ($null -eq $transformed) { + return [PSCustomObject]@{ Known = $false; Reason = "Device field '$($source.Name)' value '$rawValue' cannot be mapped safely to '$Property'." } + } + return [PSCustomObject]@{ + Known = $true + Value = $transformed + ValueType = $definition.ValueType + Operators = $definition.Operators + SourceProperty = $source.Name + } +} + +function Compare-IACVersionValue { + param([AllowNull()]$Left, [AllowNull()]$Right) + $pattern = '^\d+(?:\.\d+){0,15}$' + if ($null -eq $Left -or $null -eq $Right -or "$Left" -notmatch $pattern -or "$Right" -notmatch $pattern) { + return [PSCustomObject]@{ Known = $false; Comparison = 0 } + } + $leftParts = @("$Left" -split '\.' | ForEach-Object { [System.Numerics.BigInteger]::Parse($_) }) + $rightParts = @("$Right" -split '\.' | ForEach-Object { [System.Numerics.BigInteger]::Parse($_) }) + if ($leftParts.Count -ne $rightParts.Count) { + return [PSCustomObject]@{ Known = $false; Comparison = 0 } + } + $length = $leftParts.Count + for ($index = 0; $index -lt $length; $index++) { + $leftPart = if ($index -lt $leftParts.Count) { $leftParts[$index] } else { [System.Numerics.BigInteger]::Zero } + $rightPart = if ($index -lt $rightParts.Count) { $rightParts[$index] } else { [System.Numerics.BigInteger]::Zero } + if ($leftPart -lt $rightPart) { return [PSCustomObject]@{ Known = $true; Comparison = -1 } } + if ($leftPart -gt $rightPart) { return [PSCustomObject]@{ Known = $true; Comparison = 1 } } + } + return [PSCustomObject]@{ Known = $true; Comparison = 0 } +} + +function Compare-IACFilterScalar { + param($Actual, [string]$ValueType, $Literal, [string]$Operator) + + if ($Literal.ValueType -eq 'Array') { return 'Unknown' } + $expected = $Literal.Value + if ($null -eq $Actual -or $Literal.ValueType -eq 'Null') { + if ($Operator -notin @('eq', 'ne')) { return 'Unknown' } + $equal = $null -eq $Actual -and $Literal.ValueType -eq 'Null' + if ($Operator -eq 'ne') { $equal = -not $equal } + return $(if ($equal) { 'Match' } else { 'NotMatch' }) + } + + if ($ValueType -eq 'Version') { + $comparison = Compare-IACVersionValue -Left $Actual -Right $expected + if (-not $comparison.Known) { return 'Unknown' } + $matched = switch ($Operator) { + 'eq' { $comparison.Comparison -eq 0 } + 'ne' { $comparison.Comparison -ne 0 } + 'gt' { $comparison.Comparison -gt 0 } + 'ge' { $comparison.Comparison -ge 0 } + 'lt' { $comparison.Comparison -lt 0 } + 'le' { $comparison.Comparison -le 0 } + default { return 'Unknown' } + } + return $(if ($matched) { 'Match' } else { 'NotMatch' }) + } + + $actualText = "$Actual" + $expectedText = "$expected" + $matched = switch ($Operator) { + 'eq' { [string]::Equals($actualText, $expectedText, [System.StringComparison]::OrdinalIgnoreCase) } + 'ne' { -not [string]::Equals($actualText, $expectedText, [System.StringComparison]::OrdinalIgnoreCase) } + 'contains' { $actualText.IndexOf($expectedText, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 } + 'notcontains' { $actualText.IndexOf($expectedText, [System.StringComparison]::OrdinalIgnoreCase) -lt 0 } + 'startswith' { $actualText.StartsWith($expectedText, [System.StringComparison]::OrdinalIgnoreCase) } + default { return 'Unknown' } + } + return $(if ($matched) { 'Match' } else { 'NotMatch' }) +} + +function Invoke-IACFilterAstEvaluation { + param($Node, $Device, [System.Collections.Generic.List[string]]$Reasons) + + if ($Node.NodeType -eq 'And') { + $left = Invoke-IACFilterAstEvaluation -Node $Node.Left -Device $Device -Reasons $Reasons + $right = Invoke-IACFilterAstEvaluation -Node $Node.Right -Device $Device -Reasons $Reasons + if ($left -eq 'NotMatch' -or $right -eq 'NotMatch') { return 'NotMatch' } + if ($left -eq 'Unknown' -or $right -eq 'Unknown') { return 'Unknown' } + return 'Match' + } + if ($Node.NodeType -eq 'Or') { + $left = Invoke-IACFilterAstEvaluation -Node $Node.Left -Device $Device -Reasons $Reasons + $right = Invoke-IACFilterAstEvaluation -Node $Node.Right -Device $Device -Reasons $Reasons + if ($left -eq 'Match' -or $right -eq 'Match') { return 'Match' } + if ($left -eq 'Unknown' -or $right -eq 'Unknown') { return 'Unknown' } + return 'NotMatch' + } + + $resolved = Get-IACDeviceFilterValue -Device $Device -Property $Node.Property + if (-not $resolved.Known) { + $Reasons.Add($resolved.Reason) + return 'Unknown' + } + if ($resolved.Operators -notcontains $Node.Operator) { + $Reasons.Add("Operator '$($Node.Operator)' is not documented for '$($Node.Property)'.") + return 'Unknown' + } + if ($Node.Operator -in @('in', 'notin')) { + if ($Node.Literal.ValueType -ne 'Array') { + $Reasons.Add("Operator '$($Node.Operator)' requires an array value.") + return 'Unknown' + } + $sawUnknown = $false + $matched = $false + foreach ($literal in @($Node.Literal.Value)) { + $result = Compare-IACFilterScalar -Actual $resolved.Value -ValueType $resolved.ValueType -Literal $literal -Operator 'eq' + if ($result -eq 'Match') { $matched = $true; break } + if ($result -eq 'Unknown') { $sawUnknown = $true } + } + if (-not $matched -and $sawUnknown) { return 'Unknown' } + if ($Node.Operator -eq 'notin') { $matched = -not $matched } + return $(if ($matched) { 'Match' } else { 'NotMatch' }) + } + + $result = Compare-IACFilterScalar -Actual $resolved.Value -ValueType $resolved.ValueType -Literal $Node.Literal -Operator $Node.Operator + if ($result -eq 'Unknown') { + $Reasons.Add("Operator '$($Node.Operator)' or value type is not valid for '$($Node.Property)'.") + } + return $result +} + +function Test-IACFilterPlatformCompatibility { + param($Device, [string]$FilterPlatform) + + if ([string]::IsNullOrWhiteSpace($FilterPlatform)) { + return [PSCustomObject]@{ Compatible = $true; Reason = $null } + } + $operatingSystemProperty = Get-IACObjectProperty -InputObject $Device -Names @('operatingSystem') + if (-not $operatingSystemProperty.Found -or [string]::IsNullOrWhiteSpace("$($operatingSystemProperty.Value)")) { + return [PSCustomObject]@{ Compatible = $false; Reason = "Device response does not contain operatingSystem required to validate filter platform '$FilterPlatform'." } + } + + $platformOperatingSystems = @{ + android = @('Android') + androidforwork = @('Android') + androidworkprofile = @('Android') + androidaosp = @('Android') + ios = @('iOS', 'iPadOS') + macos = @('macOS') + windows10andlater = @('Windows') + windows81andlater = @('Windows') + windowsphone81 = @('Windows Phone') + } + $key = $FilterPlatform.ToLowerInvariant() + if (-not $platformOperatingSystems.ContainsKey($key)) { + return [PSCustomObject]@{ Compatible = $false; Reason = "Unsupported assignment filter platform '$FilterPlatform'." } + } + if ($operatingSystemProperty.Value -notin $platformOperatingSystems[$key]) { + return [PSCustomObject]@{ + Compatible = $false + Reason = "Filter platform '$FilterPlatform' is not evaluated by Intune for device operating system '$($operatingSystemProperty.Value)'." + } + } + return [PSCustomObject]@{ Compatible = $true; Reason = $null } +} + +function Test-IACAssignmentFilter { + [CmdletBinding(DefaultParameterSetName = 'Filter')] + param( + [Parameter(Mandatory, ParameterSetName = 'Filter')] + $Filter, + + [Parameter(Mandatory, ParameterSetName = 'Rule')] + [AllowEmptyString()] + [string]$Rule, + + [Parameter(Mandatory)] + [AllowNull()] + $Device, + + [Parameter()] + [string]$FilterMode = 'include' + ) + + $filterId = $filterName = $platform = $managementType = $null + $ruleText = $Rule + if ($PSCmdlet.ParameterSetName -eq 'Filter') { + $filterId = (Get-IACObjectProperty -InputObject $Filter -Names @('Id')).Value + $filterName = (Get-IACObjectProperty -InputObject $Filter -Names @('Name', 'DisplayName')).Value + $platform = (Get-IACObjectProperty -InputObject $Filter -Names @('Platform')).Value + $managementType = (Get-IACObjectProperty -InputObject $Filter -Names @('AssignmentFilterManagementType', 'ManagementType')).Value + $ruleText = (Get-IACObjectProperty -InputObject $Filter -Names @('Rule')).Value + } + + $mode = if ([string]::IsNullOrWhiteSpace($FilterMode)) { 'none' } else { $FilterMode.ToLowerInvariant() } + $ruleResult = 'Unknown' + $effectiveResult = 'Unknown' + $reason = $null + $platformCompatibility = Test-IACFilterPlatformCompatibility -Device $Device -FilterPlatform "$platform" + + if ($mode -eq 'none') { + $ruleResult = $effectiveResult = 'Match' + $reason = 'No assignment filter is applied.' + } + elseif ($mode -notin @('include', 'exclude')) { + $reason = "Unsupported assignment filter mode '$FilterMode'." + } + elseif ($managementType -and "$managementType" -ine 'devices') { + $reason = "Filter management type '$managementType' cannot be evaluated against a managed device." + } + elseif ($null -eq $Device) { + $reason = 'Managed-device properties were not provided.' + } + elseif (-not $platformCompatibility.Compatible) { + $reason = $platformCompatibility.Reason + } + elseif ([string]::IsNullOrWhiteSpace("$ruleText")) { + $reason = 'The assignment filter rule is empty.' + } + else { + try { + $tokens = ConvertTo-IACTokenList -Rule "$ruleText" + $ast = ConvertTo-IACFilterAst -Tokens $tokens + $reasons = [System.Collections.Generic.List[string]]::new() + $ruleResult = Invoke-IACFilterAstEvaluation -Node $ast -Device $Device -Reasons $reasons + $effectiveResult = if ($mode -eq 'exclude') { + switch ($ruleResult) { + 'Match' { 'NotMatch' } + 'NotMatch' { 'Match' } + default { 'Unknown' } + } + } + else { $ruleResult } + if ($ruleResult -eq 'Unknown') { + $reason = @($reasons | Select-Object -Unique) -join ' ' + if ([string]::IsNullOrWhiteSpace($reason)) { $reason = 'The rule could not be evaluated safely.' } + } + else { + $reason = "Rule evaluated to $ruleResult; $mode filter semantics produce $effectiveResult." + } + } + catch { + $reason = "Rule could not be parsed safely: $($_.Exception.Message)" + } + } + + $deviceId = (Get-IACObjectProperty -InputObject $Device -Names @('id')).Value + $resolvedDeviceName = (Get-IACObjectProperty -InputObject $Device -Names @('deviceName', 'displayName')).Value + $result = [PSCustomObject][ordered]@{ + Result = $effectiveResult + RuleResult = $ruleResult + DeviceId = $deviceId + DeviceName = $resolvedDeviceName + FilterMode = $mode + FilterId = $filterId + FilterName = $filterName + FilterPlatform = $platform + ManagementType = $managementType + Rule = $ruleText + Reason = $reason + } + $result.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentFilterEvaluation') + return $result +} diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilter.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilter.ps1 new file mode 100644 index 0000000..9e01270 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilter.ps1 @@ -0,0 +1,92 @@ +function Test-IntuneAssignmentFilter { + <# + .SYNOPSIS + Safely evaluates an Intune managed-device assignment filter locally. + + .DESCRIPTION + Resolves a managed device through Microsoft Graph beta and evaluates either a + cached tenant assignment filter or an explicit rule. Tenant rule text is parsed + as data and is never executed. Result is Match, NotMatch, or Unknown. + + .PARAMETER DeviceName + Intune managed-device name or managed-device ID. + + .PARAMETER FilterId + ID of an assignment filter cached by Connect-IntuneAssignmentChecker. + + .PARAMETER Rule + Explicit managed-device assignment-filter rule to evaluate. + + .PARAMETER FilterMode + Include applies the rule result directly; Exclude inverts Match/NotMatch. + + .PARAMETER Platform + Optional Intune assignment-filter platform for an ad hoc rule. Use the Graph + enum value, such as windows10AndLater, macOS, iOS, android, or androidAOSP. + + .EXAMPLE + Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -FilterId $filterId -FilterMode Include + + .EXAMPLE + Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -Rule '(device.deviceOwnership -eq "Corporate")' + #> + [CmdletBinding(DefaultParameterSetName = 'ByFilterId')] + [OutputType('IntuneAssignmentChecker.AssignmentFilterEvaluation')] + param( + [Parameter(Mandatory, Position = 0)] + [string]$DeviceName, + + [Parameter(Mandatory, ParameterSetName = 'ByFilterId')] + [string]$FilterId, + + [Parameter(Mandatory, ParameterSetName = 'ByRule')] + [AllowEmptyString()] + [string]$Rule, + + [Parameter()] + [ValidateSet('Include', 'Exclude', 'None')] + [string]$FilterMode = 'Include', + + [Parameter(ParameterSetName = 'ByRule')] + [ValidateSet('android', 'androidForWork', 'androidWorkProfile', 'androidAOSP', 'iOS', 'macOS', + 'windows10AndLater', 'windows81AndLater', 'windowsPhone81')] + [string]$Platform + ) + + $filterFound = $false + if ($PSCmdlet.ParameterSetName -eq 'ByFilterId') { + if ($null -eq $script:AssignmentFilterLookup) { + $script:AssignmentFilterLookup = Get-AssignmentFilterLookup + } + if ($script:AssignmentFilterLookup -and $script:AssignmentFilterLookup.ContainsKey($FilterId)) { + $filter = $script:AssignmentFilterLookup[$FilterId] + $filterFound = $true + } + else { + $filter = [PSCustomObject]@{ + Id = $FilterId; Name = $null; Platform = $null; Rule = $null + AssignmentFilterManagementType = $null + } + } + } + else { + $filter = [PSCustomObject]@{ + Id = $null; Name = 'Ad hoc rule'; Platform = $Platform; Rule = $Rule + AssignmentFilterManagementType = 'devices' + } + } + + $deviceResult = Get-IACManagedDevice -Identity $DeviceName + $evaluation = Test-IACAssignmentFilter -Filter $filter -Device $deviceResult.Device -FilterMode $FilterMode + if (-not $deviceResult.Success) { + $evaluation.Result = 'Unknown' + $evaluation.RuleResult = 'Unknown' + $evaluation.Reason = $deviceResult.Reason + } + elseif ($PSCmdlet.ParameterSetName -eq 'ByFilterId' -and -not $filterFound) { + $evaluation.Result = 'Unknown' + $evaluation.RuleResult = 'Unknown' + $evaluation.Reason = "Assignment filter '$FilterId' was not found in the tenant filter cache." + } + return $evaluation +} diff --git a/README.md b/README.md index 155c066..330fb81 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ IntuneAssignmentChecker - ๐Ÿ” Check assignments for users, groups, and devices - ๐Ÿ“ฑ View all 'All User' and 'All Device' assignments - ๐ŸŽฏ See Intune assignment filters (name and Include/Exclude type) inline on every assignment, in the console, CSV exports, and HTML reports +- ๐Ÿ›ก๏ธ Safely test managed-device assignment-filter rules locally with `Test-IntuneAssignmentFilter` and tri-state `Match`, `NotMatch`, or `Unknown` results; tenant rule text is never executed - ๐Ÿ” Support for certificate-based and client secret authentication - ๐Ÿ”„ Version check on connect with an update notice when a newer PSGallery release is available - ๐Ÿ“Š Detailed reporting of Configuration Profiles, Compliance Policies, and Applications @@ -388,6 +389,12 @@ Search-IntuneSetting -SearchTerm "BitLocker" # Return automation-friendly objects while retaining the normal console experience $records = Get-IntuneAllPolicies -PassThru $records | Where-Object AssignmentMode -eq 'Exclude' + +# Safely evaluate a cached tenant assignment filter for an Intune managed device +Test-IntuneAssignmentFilter -DeviceName 'Laptop123' -FilterId '' -FilterMode Include + +# Or evaluate an ad hoc managed-device rule without executing it as PowerShell +Test-IntuneAssignmentFilter -DeviceName 'Laptop123' -Rule '(device.deviceOwnership -eq "Corporate")' ``` `Get-IntuneUserAssignment`, `Get-IntuneGroupAssignment`, @@ -426,6 +433,14 @@ exported as empty fields, while the absence of an assignment filter is represent consistently as `None`. Values beginning with spreadsheet formula prefixes are escaped with a leading apostrophe. Use `-NoCSVReport` for HTML-only output. +`Test-IntuneAssignmentFilter` reads the managed device from the Microsoft Graph +beta `managedDevices` endpoint and returns an +`IntuneAssignmentChecker.AssignmentFilterEvaluation` object. `Result` and +`RuleResult` are always `Match`, `NotMatch`, or `Unknown`; incomplete device data, +unsupported properties or operators, managed-app rules, filter/device platform +mismatches, ambiguous devices, and malformed input remain `Unknown` rather than +being guessed. + Available cmdlets: | Cmdlet | Description | @@ -444,6 +459,7 @@ Available cmdlets: | `Compare-IntuneGroupAssignment` | Compare assignments between two or more groups | | `Test-IntuneGroupMembership` | Simulate adding a user and/or device to a group and show resulting policies | | `Test-IntuneGroupRemoval` | Simulate removing a user and/or device from a group and show lost policies | +| `Test-IntuneAssignmentFilter` | Safely evaluate a managed-device assignment filter with tri-state output | | `Search-IntunePolicy` | Reverse lookup: find all assignment targets for a policy name | | `Search-IntuneSetting` | Search configured settings across all policies | | `Update-IntuneSettingDefinition` | Refresh the local Settings Catalog definition cache | diff --git a/Tests/Unit/AssignmentFilterEvaluation.Tests.ps1 b/Tests/Unit/AssignmentFilterEvaluation.Tests.ps1 new file mode 100644 index 0000000..429c08b --- /dev/null +++ b/Tests/Unit/AssignmentFilterEvaluation.Tests.ps1 @@ -0,0 +1,398 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + . (Join-Path $moduleRoot 'Private/Test-IACAssignmentFilter.ps1') + . (Join-Path $moduleRoot 'Private/Get-AssignmentFilterLookup.ps1') + . (Join-Path $moduleRoot 'Private/Get-IACManagedDevice.ps1') + . (Join-Path $moduleRoot 'Public/Test-IntuneAssignmentFilter.ps1') + + $script:GraphEndpoint = 'https://graph.test' + function Invoke-IACGraphRequest { param([string]$Uri, [string]$Method) @{ value = @() } } + + function New-FilterTestDevice { + [PSCustomObject]@{ + id = 'managed-device-1' + deviceName = 'SURFACE-01' + operatingSystem = 'Windows' + manufacturer = 'Microsoft Corporation' + model = 'Surface Pro 9' + osVersion = '10.0.26100.1742' + managedDeviceOwnerType = 'company' + enrollmentProfileName = $null + deviceCategoryDisplayName = 'Engineering devices' + processorArchitecture = 'amd64' + joinType = 'azureADJoined' + jailBroken = 'False' + skuNumber = 48 + deviceEnrollmentType = 'windowsAzureADJoin' + } + } +} + +Describe 'Get-AssignmentFilterLookup metadata' { + It 'caches the live-verified rule, platform, and management type fields' { + Mock Invoke-IACGraphRequest { + @{ value = @([PSCustomObject]@{ + id = 'filter-1' + displayName = 'Corporate Windows' + platform = 'windows10AndLater' + rule = '(device.deviceOwnership -eq "Corporate")' + assignmentFilterManagementType = 'devices' + }) } + } + + $lookup = Get-AssignmentFilterLookup + + $lookup['filter-1'].Id | Should -BeExactly filter-1 + $lookup['filter-1'].Name | Should -BeExactly 'Corporate Windows' + $lookup['filter-1'].Platform | Should -BeExactly windows10AndLater + $lookup['filter-1'].Rule | Should -Match deviceOwnership + $lookup['filter-1'].AssignmentFilterManagementType | Should -BeExactly devices + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/deviceManagement/assignmentFilters?$select=id,displayName,platform,rule,assignmentFilterManagementType' -and + $Method -eq 'Get' + } + } +} + +Describe 'Test-IACAssignmentFilter' { + BeforeEach { + $script:device = New-FilterTestDevice + } + + Context 'parser precedence and grouping' { + It 'treats and as higher precedence than or' { + $rule = '(device.manufacturer -eq "Microsoft Corporation") or (device.model -eq "Nope") and (device.deviceOwnership -eq "Personal")' + (Test-IACAssignmentFilter -Rule $rule -Device $script:device).Result | Should -BeExactly Match + } + + It 'honors nested parentheses over default precedence' { + $rule = '((device.manufacturer -eq "Microsoft Corporation") or (device.model -eq "Nope")) and (device.deviceOwnership -eq "Personal")' + (Test-IACAssignmentFilter -Rule $rule -Device $script:device).Result | Should -BeExactly NotMatch + } + + It 'accepts documented logical and comparison aliases without hyphens' { + $rule = '(device.manufacturer equals "MICROSOFT CORPORATION") AND (device.model startsWith "surface")' + (Test-IACAssignmentFilter -Rule $rule -Device $script:device).Result | Should -BeExactly Match + } + } + + Context 'value and operator semantics' { + It 'compares strings case-insensitively with equality and inequality' { + (Test-IACAssignmentFilter -Rule '(device.model -eq "surface pro 9")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.model -ne "surface pro 8")' -Device $script:device).Result | Should -BeExactly Match + } + + It 'supports contains, notContains, and startsWith' { + (Test-IACAssignmentFilter -Rule '(device.manufacturer -contains "soft corp")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.manufacturer -notContains "apple")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.deviceName -startsWith "surface")' -Device $script:device).Result | Should -BeExactly Match + } + + It 'supports in and notIn arrays' { + (Test-IACAssignmentFilter -Rule '(device.model -in ["Latitude", "Surface Pro 9"])' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.model -notIn ["Latitude", "ThinkPad"])' -Device $script:device).Result | Should -BeExactly Match + } + + It 'supports null and $null with eq and ne' { + (Test-IACAssignmentFilter -Rule '(device.enrollmentProfileName -eq $null)' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.enrollmentProfileName -ne Null)' -Device $script:device).Result | Should -BeExactly NotMatch + } + + It 'compares arbitrary numeric version components rather than strings' { + (Test-IACAssignmentFilter -Rule '(device.operatingSystemVersion -gt 10.0.9999.9999)' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.operatingSystemVersion -le 10.0.26100.1742)' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.operatingSystemVersion -eq 10.0.26100.1742.0)' -Device $script:device).Result | Should -BeExactly Unknown + } + + It 'treats legacy osVersion as a string and rejects version-only ordering operators' { + (Test-IACAssignmentFilter -Rule '(device.osVersion -startsWith "10.0.26100")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.osVersion -gt 10.0.22000)' -Device $script:device).Result | Should -BeExactly Unknown + } + } + + Context 'explicit managed-device property map' { + It 'maps ownership, join type, SKU, rooted state, architecture, and category values' { + $rule = @' +(device.deviceOwnership -eq "Corporate") and +(device.deviceTrustType -eq "Azure AD joined") and +(device.operatingSystemSKU -eq "Professional") and +(device.isRooted -eq "False") and +(device.cpuArchitecture -eq "amd64") and +(device.deviceCategory -contains "Engineering") +'@ + (Test-IACAssignmentFilter -Rule $rule -Device $script:device).Result | Should -BeExactly Match + } + + It 'returns Unknown when a documented property is absent from the Graph response' { + $device = [PSCustomObject]@{ manufacturer = 'Microsoft' } + $result = Test-IACAssignmentFilter -Rule '(device.model -eq "Surface")' -Device $device + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -Match 'does not contain a source field' + } + + It 'returns Unknown for a management type that cannot be mapped without guessing' { + $script:device.deviceEnrollmentType = 'androidEnterpriseDedicatedDevice' + (Test-IACAssignmentFilter -Rule '(device.deviceManagementType -eq "Corporate-owned dedicated devices with Entra ID Shared mode")' -Device $script:device).Result | + Should -BeExactly Unknown + } + + It 'maps the live beta AOSP enrollment enum names' { + $script:device.deviceEnrollmentType = 'androidAOSPUserOwnedDeviceEnrollment' + (Test-IACAssignmentFilter -Rule '(device.deviceManagementType -eq "AOSP user-associated devices")' -Device $script:device).Result | + Should -BeExactly Match + $script:device.deviceEnrollmentType = 'androidAOSPUserlessDeviceEnrollment' + (Test-IACAssignmentFilter -Rule '(device.deviceManagementType -eq "AOSP userless devices")' -Device $script:device).Result | + Should -BeExactly Match + } + + It 'maps skuNumber one-to-one and refuses a coarse skuFamily fallback' { + (Test-IACAssignmentFilter -Rule '(device.operatingSystemSKU -eq "Professional")' -Device $script:device).Result | Should -BeExactly Match + $device = [PSCustomObject]@{ skuFamily = 'Pro' } + (Test-IACAssignmentFilter -Rule '(device.operatingSystemSKU -eq "Professional")' -Device $device).Result | Should -BeExactly Unknown + } + + It 'pins documented Unknown ownership and trust values while rejecting unknown enrollment mappings' { + $script:device.managedDeviceOwnerType = 'unknown' + $script:device.joinType = 'unknown' + $script:device.deviceEnrollmentType = 'windowsCoManagement' + + (Test-IACAssignmentFilter -Rule '(device.deviceOwnership -eq "Unknown")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.deviceTrustType -eq "Unknown")' -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule '(device.deviceManagementType -eq "Unknown")' -Device $script:device).Result | Should -BeExactly Unknown + } + } + + Context 'tri-state propagation' { + It 'never turns unsupported properties into a definitive answer' { + (Test-IACAssignmentFilter -Rule '(device.serialNumber -eq "secret")' -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule '(app.deviceModel -eq "Surface")' -Device $script:device).Result | Should -BeExactly Unknown + } + + It 'uses three-valued and/or truth tables' { + $unknown = '(device.serialNumber -eq "x")' + (Test-IACAssignmentFilter -Rule ($unknown + ' and (device.model -eq "Nope")') -Device $script:device).Result | Should -BeExactly NotMatch + (Test-IACAssignmentFilter -Rule ($unknown + ' and (device.model -eq "Surface Pro 9")') -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule ($unknown + ' or (device.model -eq "Surface Pro 9")') -Device $script:device).Result | Should -BeExactly Match + (Test-IACAssignmentFilter -Rule ($unknown + ' or (device.model -eq "Nope")') -Device $script:device).Result | Should -BeExactly Unknown + } + + It 'returns Unknown for invalid versions, operators, and array shapes' { + $script:device.osVersion = '26.0 (25A5349a)' + (Test-IACAssignmentFilter -Rule '(device.operatingSystemVersion -gt 25.0)' -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule '(device.model -endsWith "9")' -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule '(device.model -in "Surface Pro 9")' -Device $script:device).Result | Should -BeExactly Unknown + } + } + + Context 'include and exclude filter semantics' { + It 'preserves rule results for include filters and inverts them for exclude filters' { + $rule = '(device.deviceOwnership -eq "Corporate")' + $include = Test-IACAssignmentFilter -Rule $rule -Device $script:device -FilterMode include + $exclude = Test-IACAssignmentFilter -Rule $rule -Device $script:device -FilterMode exclude + + $include.RuleResult | Should -BeExactly Match + $include.Result | Should -BeExactly Match + $exclude.RuleResult | Should -BeExactly Match + $exclude.Result | Should -BeExactly NotMatch + } + + It 'returns Match without parsing when no filter is applied' { + (Test-IACAssignmentFilter -Rule 'hostile syntax' -Device $script:device -FilterMode none).Result | Should -BeExactly Match + } + + It 'keeps Unknown unknown under exclude semantics' { + (Test-IACAssignmentFilter -Rule '(device.unsupported -eq "x")' -Device $script:device -FilterMode exclude).Result | Should -BeExactly Unknown + } + + It 'uses cached filter metadata and rejects managed-app filters for a device' { + $filter = [PSCustomObject]@{ + Id = 'filter-app' + Name = 'Managed app filter' + Platform = 'iOSMobileApplicationManagement' + Rule = '(app.deviceModel -eq "iPhone")' + AssignmentFilterManagementType = 'apps' + } + $result = Test-IACAssignmentFilter -Filter $filter -Device $script:device -FilterMode include + + $result.Result | Should -BeExactly Unknown + $result.FilterId | Should -BeExactly filter-app + $result.ManagementType | Should -BeExactly apps + } + + It 'evaluates a cached managed-device filter object end to end' { + $filter = [PSCustomObject]@{ + Id = 'filter-device' + Name = 'Corporate devices' + Platform = 'windows10AndLater' + Rule = '(device.deviceOwnership -eq "Corporate")' + AssignmentFilterManagementType = 'devices' + } + $result = Test-IACAssignmentFilter -Filter $filter -Device $script:device -FilterMode include + + $result.Result | Should -BeExactly Match + $result.DeviceId | Should -BeExactly managed-device-1 + $result.DeviceName | Should -BeExactly SURFACE-01 + $result.FilterName | Should -BeExactly 'Corporate devices' + } + + It 'returns Unknown when the filter platform does not apply to the device OS' { + $filter = [PSCustomObject]@{ + Id = 'filter-macos'; Name = 'macOS corporate'; Platform = 'macOS' + Rule = '(device.deviceOwnership -eq "Corporate")'; AssignmentFilterManagementType = 'devices' + } + $result = Test-IACAssignmentFilter -Filter $filter -Device $script:device -FilterMode include + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -Match "not evaluated by Intune for device operating system 'Windows'" + } + } + + Context 'hostile and malformed input' { + It 'does not execute tenant-provided rule text' { + $marker = Join-Path $TestDrive 'executed.txt' + $rules = @( + "(device.model -eq `"Surface Pro 9`"); Set-Content -Path '$marker' -Value pwned" + "`$(Set-Content -Path '$marker' -Value pwned)" + '(device.model -eq "Surface Pro 9") | Out-Null' + '(device.model -eq "Surface Pro 9") { Get-Process }' + ) + + foreach ($rule in $rules) { + (Test-IACAssignmentFilter -Rule $rule -Device $script:device).Result | Should -BeExactly Unknown + } + $literal = "`$(Set-Content -Path '$marker' -Value pwned)" + $script:device.model = $literal + (Test-IACAssignmentFilter -Rule "(device.model -eq '`$`(Set-Content -Path ''$marker'' -Value pwned)')" -Device $script:device).Result | + Should -BeExactly Match + $marker | Should -Not -Exist + (Get-Content -Raw (Join-Path $moduleRoot 'Private/Test-IACAssignmentFilter.ps1')) | + Should -Not -Match 'Invoke-Expression|\biex\b|ScriptBlock\]::Create' + } + + It 'returns Unknown for malformed strings, delimiters, and excessive input' { + (Test-IACAssignmentFilter -Rule '(device.model -eq "unterminated)' -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule '((device.model -eq "Surface Pro 9")' -Device $script:device).Result | Should -BeExactly Unknown + (Test-IACAssignmentFilter -Rule ('x' * 8193) -Device $script:device).Result | Should -BeExactly Unknown + } + + It 'enforces nesting and token safety limits' { + $nested = ('(' * 65) + '(device.model -eq "Surface Pro 9")' + (')' * 65) + $tokenHeavy = (('x ' * 1025).Trim()) + + (Test-IACAssignmentFilter -Rule $nested -Device $script:device).Reason | Should -Match '64-level safety limit' + (Test-IACAssignmentFilter -Rule $tokenHeavy -Device $script:device).Reason | Should -Match '1024-token safety limit' + } + } +} + +Describe 'Test-IntuneAssignmentFilter public workflow' { + BeforeEach { + $script:AssignmentFilterLookup = @{ + 'filter-1' = [PSCustomObject]@{ + Id = 'filter-1'; Name = 'Corporate Windows'; Platform = 'windows10AndLater' + Rule = '(device.deviceOwnership -eq "Corporate")'; AssignmentFilterManagementType = 'devices' + } + } + Mock Invoke-IACGraphRequest { + @{ value = @([PSCustomObject]@{ + id = 'managed-1'; deviceName = 'SURFACE-01'; operatingSystem = 'Windows'; osVersion = '10.0.26100.1742' + manufacturer = 'Microsoft'; model = 'Surface Pro 9'; managedDeviceOwnerType = 'company' + enrollmentProfileName = $null; skuNumber = 48; deviceCategoryDisplayName = 'Engineering' + jailBroken = 'False'; processorArchitecture = 'amd64'; joinType = 'azureADJoined' + deviceEnrollmentType = 'windowsAzureADJoin'; managementAgent = 'mdm' + }) } + } + } + + It 'resolves a beta managed device and evaluates a cached filter' { + $result = Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -FilterId filter-1 + + $result.Result | Should -BeExactly Match + $result.FilterId | Should -BeExactly filter-1 + $result.DeviceId | Should -BeExactly managed-1 + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -like 'https://graph.test/beta/deviceManagement/managedDevices?*' -and + $Uri -match '\$filter=deviceName%20eq%20%27SURFACE-01%27' -and + $Uri -match '\$select=.*skuNumber' -and + $Uri -notmatch 'skuFamily|deviceOwnership' + } + } + + It 'OData-escapes quotes and URL-encodes device-name metacharacters' { + Test-IntuneAssignmentFilter -DeviceName "R&D + 100% #1 O'Brien" -Rule '(device.model -eq "Surface Pro 9")' | Out-Null + + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -match '\$filter=deviceName%20eq%20%27R%26D%20%2B%20100%25%20%231%20O%27%27Brien%27' -and + $Uri -notmatch "R&D|O'Brien" + } + } + + It 'validates and applies the documented ad hoc platform vocabulary' { + $result = Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -Rule '(device.model -eq "Surface Pro 9")' -Platform windows10AndLater + + $result.Result | Should -BeExactly Match + { Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -Rule '(device.model -eq "Surface Pro 9")' -Platform Windows } | + Should -Throw -ExceptionType ([System.Management.Automation.ParameterBindingException]) + } + + It 'returns Unknown when managed-device resolution is ambiguous' { + Mock Invoke-IACGraphRequest { @{ value = @(@{ id = 'one' }, @{ id = 'two' }) } } + + $result = Test-IntuneAssignmentFilter -DeviceName 'DUPLICATE' -FilterId filter-1 + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -Match 'Multiple managed devices' + } + + It 'returns Unknown when a filter id is not cached' { + $result = Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -FilterId missing + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -Match "filter 'missing' was not found" + $result.ManagementType | Should -BeNullOrEmpty + } + + It 'preserves the empty-rule reason for a filter that is present in the cache' { + $script:AssignmentFilterLookup['empty'] = [PSCustomObject]@{ + Id = 'empty'; Name = 'Empty rule'; Platform = 'windows10AndLater'; Rule = ''; AssignmentFilterManagementType = 'devices' + } + + $result = Test-IntuneAssignmentFilter -DeviceName 'SURFACE-01' -FilterId empty + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -BeExactly 'The assignment filter rule is empty.' + } + + It 'uses the direct single-object beta route for a managed-device GUID' { + $managedDeviceId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + Mock Invoke-IACGraphRequest { + [PSCustomObject]@{ + id = $managedDeviceId; deviceName = 'DIRECT-01'; operatingSystem = 'Windows' + managedDeviceOwnerType = 'company'; skuNumber = 48 + } + } -ParameterFilter { $Uri -like "*/managedDevices/$managedDeviceId*" } + + $result = Test-IntuneAssignmentFilter -DeviceName $managedDeviceId -FilterId filter-1 + + $result.Result | Should -BeExactly Match + $result.DeviceId | Should -BeExactly $managedDeviceId + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -like "https://graph.test/beta/deviceManagement/managedDevices/$managedDeviceId`?*" -and + $Uri -match '\$select=.*skuNumber' -and $Uri -notmatch 'skuFamily|deviceOwnership' + } + } + + It 'returns Unknown when a managed-device GUID does not resolve' { + $managedDeviceId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + Mock Invoke-IACGraphRequest { @{} } -ParameterFilter { $Uri -like "*/managedDevices/$managedDeviceId*" } + + $result = Test-IntuneAssignmentFilter -DeviceName $managedDeviceId -FilterId filter-1 + + $result.Result | Should -BeExactly Unknown + $result.Reason | Should -BeExactly "Managed device '$managedDeviceId' was not found." + } +} From d57a6277dcd9cfc80c8331815b38bb9b7f115c11 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:34:20 +0200 Subject: [PATCH 5/8] Add effective assignment targeting analysis (#140) --- .../IntuneAssignmentChecker.psd1 | 2 + .../Private/ConvertTo-IACCsvSafeValue.ps1 | 12 + .../Private/Get-GroupMemberships.ps1 | 2 +- .../Private/Get-IACDirectoryDevice.ps1 | 24 + .../Get-IACNoAssignmentPlaceholder.ps1 | 19 + .../Private/Get-IntuneCategoryDefinition.ps1 | 28 +- .../Private/Invoke-IntuneCategoryScan.ps1 | 6 +- .../Private/New-IACAssignmentRecord.ps1 | 6 + .../Resolve-IACEffectiveAssignment.ps1 | 238 +++++++++ .../Public/Get-IntuneEffectiveAssignment.ps1 | 272 ++++++++++ README.md | 38 +- Tests/Unit/AssignmentRecord.Tests.ps1 | 3 + Tests/Unit/CategoryScan.Tests.ps1 | 11 +- Tests/Unit/CompareGroupAssignment.Tests.ps1 | 1 + Tests/Unit/DeviceAssignment.Tests.ps1 | 1 + Tests/Unit/EffectiveAssignment.Tests.ps1 | 488 ++++++++++++++++++ Tests/Unit/GraphMembership.Tests.ps1 | 64 +++ Tests/Unit/GroupAssignment.Tests.ps1 | 1 + Tests/Unit/SearchPassThru.Tests.ps1 | 2 +- Tests/Unit/TestGroupMembership.Tests.ps1 | 1 + Tests/Unit/TestGroupRemoval.Tests.ps1 | 1 + Tests/Unit/UserAssignment.Tests.ps1 | 1 + 22 files changed, 1209 insertions(+), 12 deletions(-) create mode 100644 Module/IntuneAssignmentChecker/Private/ConvertTo-IACCsvSafeValue.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Get-IACDirectoryDevice.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Get-IACNoAssignmentPlaceholder.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Resolve-IACEffectiveAssignment.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Get-IntuneEffectiveAssignment.ps1 create mode 100644 Tests/Unit/EffectiveAssignment.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index 8457698..aa3bd1d 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -15,6 +15,7 @@ 'Get-IntuneGroupAssignment' 'Get-IntuneDeviceAssignment' 'Get-IntuneUserDeviceAssignment' + 'Get-IntuneEffectiveAssignment' 'Get-IntuneAllPolicies' 'Get-IntuneAllUsersAssignment' 'Get-IntuneAllDevicesAssignment' @@ -49,6 +50,7 @@ Version 4.4.0: - Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). - Cover Windows Feature Update, Quality Update, Driver Update, and Quality Update policy assignments across shared scans, searches, comparisons, exports, and reports (issue #138). - Add Test-IntuneAssignmentFilter for safe, local tri-state evaluation of documented managed-device filter rules without executing tenant-provided text (issue #139). +- Add Get-IntuneEffectiveAssignment with user/device targeting precedence, filter evaluation, machine-readable reason chains, PassThru, and CSV output (issue #140). Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). diff --git a/Module/IntuneAssignmentChecker/Private/ConvertTo-IACCsvSafeValue.ps1 b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACCsvSafeValue.ps1 new file mode 100644 index 0000000..7741ffb --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/ConvertTo-IACCsvSafeValue.ps1 @@ -0,0 +1,12 @@ +function ConvertTo-IACCsvSafeValue { + [CmdletBinding()] + param( + [AllowNull()] + $Value + ) + + if ($null -eq $Value) { return $null } + $text = "$Value" + if ($text -match '^[=+\-@\t\r]') { return "'$text" } + return $text +} diff --git a/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 b/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 index 7f11c0a..2063bf9 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-GroupMemberships.ps1 @@ -10,7 +10,7 @@ function Get-GroupMemberships { ) $memberships = [System.Collections.ArrayList]::new() - $uri = "$script:GraphEndpoint/beta/$($ObjectType.ToLower())s/$ObjectId/transitiveMemberOf?`$select=id,displayName" + $uri = "$script:GraphEndpoint/beta/$($ObjectType.ToLower())s/$ObjectId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName" try { $pagedMemberships = @((Invoke-IACGraphRequest -Uri $uri -Method Get).value) diff --git a/Module/IntuneAssignmentChecker/Private/Get-IACDirectoryDevice.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IACDirectoryDevice.ps1 new file mode 100644 index 0000000..fc84af1 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Get-IACDirectoryDevice.ps1 @@ -0,0 +1,24 @@ +function Get-IACDirectoryDevice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$AzureADDeviceId + ) + + try { + $escapedId = $AzureADDeviceId -replace "'", "''" + $encodedFilter = [uri]::EscapeDataString("deviceId eq '$escapedId'") + $uri = "$script:GraphEndpoint/beta/devices?`$filter=$encodedFilter&`$select=id,displayName,deviceId" + $devices = @((Invoke-IACGraphRequest -Uri $uri -Method Get).value) + if ($devices.Count -eq 1 -and $devices[0].id) { + return [PSCustomObject]@{ Success = $true; Device = $devices[0]; Reason = $null } + } + if ($devices.Count -gt 1) { + return [PSCustomObject]@{ Success = $false; Device = $null; Reason = "Multiple Entra devices use deviceId '$AzureADDeviceId'." } + } + return [PSCustomObject]@{ Success = $false; Device = $null; Reason = "No Entra device maps to managedDevice.azureADDeviceId '$AzureADDeviceId'." } + } + catch { + return [PSCustomObject]@{ Success = $false; Device = $null; Reason = "Entra device lookup failed: $($_.Exception.Message)" } + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Get-IACNoAssignmentPlaceholder.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IACNoAssignmentPlaceholder.ps1 new file mode 100644 index 0000000..5ae36db --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Get-IACNoAssignmentPlaceholder.ps1 @@ -0,0 +1,19 @@ +function Get-IACNoAssignmentPlaceholder { + [CmdletBinding()] + param( + [string]$Reason = 'No Assignment' + ) + + [PSCustomObject][ordered]@{ + AssignmentId = $null + Reason = $Reason + AssignmentMode = 'None' + TargetType = 'None' + TargetId = $null + GroupId = $null + Intent = $null + Apps = $null + FilterId = $null + FilterType = $null + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 index 0688c57..3a4f904 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneCategoryDefinition.ps1 @@ -2,7 +2,7 @@ function Get-IntuneCategoryDefinition { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] - [ValidateSet('UserContext', 'DeviceContext', 'GroupContext', 'AllPolicies', 'Search', 'Compare')] + [ValidateSet('UserContext', 'DeviceContext', 'GroupContext', 'AllPolicies', 'Search', 'Compare', 'Effective')] [string]$Audience ) @@ -272,5 +272,31 @@ function Get-IntuneCategoryDefinition { $categories += @(& $newEsCategories { param($family) $family.Export }) return $categories } + 'Effective' { + # Full, deduplicated inventory for user/device targeting analysis. + # Endpoint Security policies are handled by their family categories, + # so the generic Settings Catalog category excludes those policies. + $categories = @( + & $use 'DeviceConfigurations' + & $use 'ImportedAdministrativeTemplates' + & $use 'SettingsCatalog' @{ EntityFilter = $searchSettingsCatalogFilter } + & $use 'CompliancePolicies' + & $use 'AppProtectionPolicies' + & $use 'AppConfigurationPolicies' + & $use 'Applications' + & $use 'PlatformScripts' + & $use 'HealthScripts' + & $use 'DeploymentProfiles' + & $use 'ESPProfiles' + & $use 'CloudPCProvisioningPolicies' + & $use 'CloudPCUserSettings' + & $use 'WindowsFeatureUpdates' + & $use 'WindowsQualityUpdates' + & $use 'WindowsDriverUpdates' + & $use 'WindowsQualityUpdatePolicies' + ) + $categories += @(& $newEsCategories { param($family) "$($family.ShortName) Policies" }) + return $categories + } } } diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index db1134d..ba765a7 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -89,11 +89,7 @@ function Invoke-IntuneCategoryScan { $entityRecords = [System.Collections.Generic.List[object]]::new() if ($BuildRecords) { if ($Assignments.Count -eq 0) { - $noneAssignment = [PSCustomObject]@{ - AssignmentId = $null; Reason = 'No Assignment'; AssignmentMode = 'None' - TargetType = 'None'; TargetId = $null; GroupId = $null; Intent = $null - FilterId = $null; FilterType = $null - } + $noneAssignment = Get-IACNoAssignmentPlaceholder $entityRecords.Add((ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $noneAssignment)) } else { diff --git a/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 index 4b3a372..544bb1d 100644 --- a/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 +++ b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 @@ -29,6 +29,10 @@ function New-IACAssignmentRecord { [string]$FilterMode, [string]$FilterRule, [string]$FilterPlatform, + [ValidateSet('Included', 'Excluded', 'NotTargeted', 'Unknown')] + [AllowNull()] + [string]$EffectiveState, + [object[]]$ReasonChain = @(), [string]$SubjectType, [string]$SubjectId, [string]$SubjectName, @@ -61,6 +65,8 @@ function New-IACAssignmentRecord { FilterMode = $FilterMode FilterRule = $FilterRule FilterPlatform = $FilterPlatform + EffectiveState = $EffectiveState + ReasonChain = @($ReasonChain) AssignmentReason = $AssignmentReason Source = $Source } diff --git a/Module/IntuneAssignmentChecker/Private/Resolve-IACEffectiveAssignment.ps1 b/Module/IntuneAssignmentChecker/Private/Resolve-IACEffectiveAssignment.ps1 new file mode 100644 index 0000000..df44a58 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Resolve-IACEffectiveAssignment.ps1 @@ -0,0 +1,238 @@ +function Resolve-IACEffectiveAssignment { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Category, + [Parameter(Mandatory)]$Entity, + [AllowEmptyCollection()][object[]]$Assignments = @(), + [Parameter(Mandatory)][hashtable]$MembershipSources, + [switch]$HasUser, + [switch]$HasDevice, + [bool]$UserMembershipKnown = $true, + [bool]$DeviceMembershipKnown = $true, + [AllowNull()]$ManagedDevice, + [Parameter(Mandatory)][string]$SubjectType, + [AllowEmptyString()][string]$SubjectId, + [Parameter(Mandatory)][string]$SubjectName + ) + + $reasonChain = [System.Collections.Generic.List[object]]::new() + $candidates = [System.Collections.Generic.List[object]]::new() + $sequence = 0 + + foreach ($assignment in @($Assignments)) { + $sequence++ + $targetResult = 'Unknown' + $membershipSourceNames = @() + $targetDimensions = [System.Collections.Generic.List[string]]::new() + $targetCode = 'Target.Unsupported' + $targetMessage = "Unsupported assignment target type '$($assignment.TargetType)'." + + switch ($assignment.TargetType) { + 'AllUsers' { + $targetResult = if ($HasUser) { 'Match' } else { 'NotMatch' } + if ($HasUser) { [void]$targetDimensions.Add('User') } + $targetCode = 'Target.AllUsers' + $targetMessage = if ($HasUser) { 'A user subject is present.' } else { 'No user subject was supplied.' } + } + 'AllDevices' { + $targetResult = if ($HasDevice) { 'Match' } else { 'NotMatch' } + if ($HasDevice) { [void]$targetDimensions.Add('Device') } + $targetCode = 'Target.AllDevices' + $targetMessage = if ($HasDevice) { 'A managed-device subject is present.' } else { 'No device subject was supplied.' } + } + 'Group' { + if ($assignment.TargetId -and $MembershipSources.ContainsKey("$($assignment.TargetId)")) { + $targetResult = 'Match' + $membershipSourceNames = @($MembershipSources["$($assignment.TargetId)"].Sources) + foreach ($source in $membershipSourceNames) { + if ($source -in @('User', 'Device') -and -not $targetDimensions.Contains($source)) { + [void]$targetDimensions.Add($source) + } + } + $targetCode = 'Target.TransitiveGroupMembership' + $targetMessage = "The subject is a transitive member through: $($membershipSourceNames -join ', ')." + } + elseif (($HasUser -and -not $UserMembershipKnown) -or ($HasDevice -and -not $DeviceMembershipKnown)) { + $targetResult = 'Unknown' + if ($HasUser -and -not $UserMembershipKnown) { [void]$targetDimensions.Add('User') } + if ($HasDevice -and -not $DeviceMembershipKnown) { [void]$targetDimensions.Add('Device') } + $targetCode = 'Target.GroupMembershipUnknown' + $targetMessage = 'At least one supplied subject has an incomplete transitive group-membership result.' + } + else { + $targetResult = 'NotMatch' + $targetCode = 'Target.GroupNotMember' + $targetMessage = 'Neither supplied subject is a transitive member of the target group.' + } + } + } + + $filterResult = 'NotEvaluated' + $filterCode = 'Filter.NotEvaluated' + $filterReason = 'The target did not match, so its assignment filter was not evaluated.' + if ($targetResult -eq 'Match') { + $filterId = "$($assignment.FilterId)" + $filterMode = "$($assignment.FilterType)" + $hasFilterId = -not [string]::IsNullOrWhiteSpace($filterId) -and + $filterId -ne '00000000-0000-0000-0000-000000000000' + if (-not $hasFilterId -or $filterMode -ieq 'none') { + $filterResult = 'Match' + $filterCode = 'Filter.None' + $filterReason = 'No assignment filter is applied.' + } + elseif ([string]::IsNullOrWhiteSpace($filterMode) -or $filterMode -notin @('include', 'exclude')) { + $filterResult = 'Unknown' + $filterCode = 'Filter.UnsupportedMode' + $filterReason = "Unsupported assignment filter mode '$filterMode'." + } + elseif ($null -eq $ManagedDevice) { + $filterResult = 'Unknown' + $filterCode = 'Filter.NoDevice' + $filterReason = 'A managed device is required to evaluate this assignment filter.' + } + elseif (-not $script:AssignmentFilterLookup -or -not $script:AssignmentFilterLookup.ContainsKey($filterId)) { + $filterResult = 'Unknown' + $filterCode = 'Filter.NotInCache' + $filterReason = "Assignment filter '$filterId' is not present in the tenant filter cache." + } + else { + $filterEvaluation = Test-IACAssignmentFilter ` + -Filter $script:AssignmentFilterLookup[$filterId] ` + -Device $ManagedDevice -FilterMode $filterMode + $filterResult = $filterEvaluation.Result + $filterCode = "Filter.Evaluated.$filterResult" + $filterReason = $filterEvaluation.Reason + } + } + elseif ($targetResult -eq 'Unknown') { + $filterResult = 'Unknown' + $filterCode = 'Filter.TargetUnknown' + $filterReason = 'Filter evaluation is blocked because target membership is unknown.' + } + + $candidateOutcome = if ($targetResult -eq 'Unknown' -or $filterResult -eq 'Unknown') { 'Unknown' } + elseif ($targetResult -ne 'Match' -or $filterResult -ne 'Match') { 'Inactive' } + elseif ($assignment.AssignmentMode -eq 'Exclude') { 'Excluded' } + elseif ($assignment.AssignmentMode -eq 'Include') { 'Included' } + else { 'Unknown' } + + [void]$reasonChain.Add([PSCustomObject][ordered]@{ + Sequence = $sequence + Code = $targetCode + FilterCode = $filterCode + Outcome = $candidateOutcome + AssignmentId = $assignment.AssignmentId + AssignmentMode = $assignment.AssignmentMode + TargetType = $assignment.TargetType + TargetId = $assignment.TargetId + MembershipSources = @($membershipSourceNames) + TargetResult = $targetResult + FilterId = $assignment.FilterId + FilterMode = $assignment.FilterType + FilterResult = $filterResult + Message = "$targetMessage $filterReason" + }) + [void]$candidates.Add([PSCustomObject]@{ + Assignment = $assignment + Outcome = $candidateOutcome + Dimensions = @($targetDimensions) + }) + } + + $activeExclusions = @($candidates | Where-Object Outcome -eq Excluded) + $unknownExclusions = @($candidates | Where-Object { $_.Outcome -eq 'Unknown' -and $_.Assignment.AssignmentMode -eq 'Exclude' }) + $activeInclusions = @($candidates | Where-Object Outcome -eq Included) + $unknownInclusions = @($candidates | Where-Object { $_.Outcome -eq 'Unknown' -and $_.Assignment.AssignmentMode -eq 'Include' }) + $unknownOther = @($candidates | Where-Object { + $_.Outcome -eq 'Unknown' -and $_.Assignment.AssignmentMode -notin @('Include', 'Exclude') + }) + + $compatibleExclusion = $null + if ($activeInclusions.Count -gt 0 -and $activeExclusions.Count -gt 0) { + foreach ($exclusion in $activeExclusions) { + foreach ($inclusion in $activeInclusions) { + if (@($exclusion.Dimensions | Where-Object { $_ -in $inclusion.Dimensions }).Count -gt 0) { + $compatibleExclusion = $exclusion + break + } + } + if ($compatibleExclusion) { break } + } + } + + if ($activeInclusions.Count -gt 0) { + if ($compatibleExclusion) { + $effectiveState = 'Excluded'; $representative = $compatibleExclusion.Assignment + $decisionCode = 'Decision.Excluded' + $decisionMessage = 'A matching exclusion in the same user/device targeting dimension takes precedence over an inclusion.' + } + elseif ($activeExclusions.Count -gt 0) { + $effectiveState = 'Unknown'; $representative = $activeExclusions[0].Assignment + $decisionCode = 'Decision.CrossDimensionUnknown' + $decisionMessage = 'Inclusion and exclusion match different user/device targeting dimensions; Intune behavior cannot be inferred safely.' + } + elseif ($unknownExclusions.Count -gt 0) { + $effectiveState = 'Unknown'; $representative = $unknownExclusions[0].Assignment + $decisionCode = 'Decision.UnresolvedExclusion' + $decisionMessage = 'A possible exclusion prevents a definitive inclusion result.' + } + elseif ($unknownOther.Count -gt 0) { + $effectiveState = 'Unknown'; $representative = $unknownOther[0].Assignment + $decisionCode = 'Decision.UnknownAssignmentMode' + $decisionMessage = 'An assignment uses an unsupported include/exclude mode.' + } + else { + $effectiveState = 'Included'; $representative = $activeInclusions[0].Assignment + $decisionCode = 'Decision.Included' + $decisionMessage = 'At least one inclusion is active and no exclusion can override it.' + } + } + elseif ($unknownInclusions.Count -gt 0) { + $effectiveState = 'Unknown'; $representative = $unknownInclusions[0].Assignment + $decisionCode = 'Decision.UnresolvedInclusion' + $decisionMessage = 'A possible inclusion could not be evaluated definitively.' + } + elseif ($unknownOther.Count -gt 0) { + $effectiveState = 'Unknown'; $representative = $unknownOther[0].Assignment + $decisionCode = 'Decision.UnknownAssignmentMode' + $decisionMessage = 'An assignment uses an unsupported include/exclude mode.' + } + else { + $effectiveState = 'NotTargeted' + $representative = if ($Assignments.Count -eq 0) { + Get-IACNoAssignmentPlaceholder + } + else { + Get-IACNoAssignmentPlaceholder -Reason 'No Matching Assignment' + } + $decisionCode = 'Decision.NotTargeted' + $decisionMessage = if ($Assignments.Count -eq 0) { 'The policy has no assignments.' } else { 'No inclusion targets either supplied subject.' } + } + + $sequence++ + [void]$reasonChain.Add([PSCustomObject][ordered]@{ + Sequence = $sequence + Code = $decisionCode + FilterCode = $null + Outcome = $effectiveState + AssignmentId = $representative.AssignmentId + AssignmentMode = $representative.AssignmentMode + TargetType = $representative.TargetType + TargetId = $representative.TargetId + MembershipSources = @() + TargetResult = $null + FilterId = $representative.FilterId + FilterMode = $representative.FilterType + FilterResult = $null + Message = $decisionMessage + }) + + $record = ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $representative ` + -SubjectType $SubjectType -SubjectId $SubjectId -SubjectName $SubjectName -Source 'Get-IntuneEffectiveAssignment' + if ($representative.TargetType -eq 'Group' -and $representative.TargetId -and $MembershipSources.ContainsKey("$($representative.TargetId)")) { + $record.TargetName = $MembershipSources["$($representative.TargetId)"].Name + } + $record.EffectiveState = $effectiveState + $record.ReasonChain = @($reasonChain) + return $record +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneEffectiveAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneEffectiveAssignment.ps1 new file mode 100644 index 0000000..7fd1a9e --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneEffectiveAssignment.ps1 @@ -0,0 +1,272 @@ +function Get-IntuneEffectiveAssignment { + <# + .SYNOPSIS + Explains effective Intune assignment targeting for a user, a managed device, or both. + + .DESCRIPTION + Combines All Users, All Devices, transitive user/device group membership, + group exclusions, and assignment-filter evaluation. Results are Included, + Excluded, NotTargeted, or Unknown with a machine-readable ReasonChain. + + This command explains assignment targeting only. It does not prove delivery, + platform applicability, installation, execution, compliance, or device check-in. + + .PARAMETER UserPrincipalName + Optional user principal name. Supply a user, a managed device, or both. + + .PARAMETER DeviceName + Optional Intune managed-device name or managed-device ID. + + .PARAMETER PassThru + Returns IntuneAssignmentChecker.AssignmentRecord objects with EffectiveState + and ReasonChain fields. + + .PARAMETER ExportToCSV + Exports effective results, including a compact JSON reason chain, to CSV. + + .PARAMETER ExportPath + CSV destination. The path must end in .csv. Supplying it enables export. + + .EXAMPLE + Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' + + .EXAMPLE + Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' -DeviceName 'WIN-01' -PassThru + + .EXAMPLE + Get-IntuneEffectiveAssignment -DeviceName 'WIN-01' -ExportPath './effective.csv' + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] + param( + [Parameter()] + [string]$UserPrincipalName, + + [Parameter()] + [string]$DeviceName, + + [Parameter()] + [switch]$PassThru, + + [Parameter()] + [switch]$ExportToCSV, + + [Parameter()] + [string]$ExportPath + ) + + if ([string]::IsNullOrWhiteSpace($UserPrincipalName) -and [string]::IsNullOrWhiteSpace($DeviceName)) { + Write-Error 'Supply -UserPrincipalName, -DeviceName, or both.' + return + } + if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) { + Write-Error 'Connect first with Connect-IntuneAssignmentChecker.' + return + } + if (-not [string]::IsNullOrWhiteSpace($ExportPath) -and [System.IO.Path]::GetExtension($ExportPath) -ine '.csv') { + Write-Error 'ExportPath must be a .csv file.' + return + } + + $userInfo = $null + $managedDevice = $null + $directoryDevice = $null + if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { + $userInfo = Get-UserInfo -UserPrincipalName $UserPrincipalName.Trim() + if (-not $userInfo.Success) { + Write-Error "User '$UserPrincipalName' was not found." + return + } + } + if (-not [string]::IsNullOrWhiteSpace($DeviceName)) { + $managedDeviceResult = Get-IACManagedDevice -Identity $DeviceName.Trim() + if (-not $managedDeviceResult.Success) { + Write-Error $managedDeviceResult.Reason + return + } + $managedDevice = $managedDeviceResult.Device + if (-not [string]::IsNullOrWhiteSpace("$($managedDevice.azureADDeviceId)")) { + $directoryResult = Get-IACDirectoryDevice -AzureADDeviceId "$($managedDevice.azureADDeviceId)" + if ($directoryResult.Success) { $directoryDevice = $directoryResult.Device } + else { Write-Warning $directoryResult.Reason } + } + else { + Write-Warning 'The managed device has no azureADDeviceId; device group targeting will remain Unknown.' + } + } + + if ($null -eq $script:AssignmentFilterLookup) { + $script:AssignmentFilterLookup = Get-AssignmentFilterLookup + } + + $membershipSources = @{} + $userMembershipKnown = $true + $deviceMembershipKnown = $true + if ($userInfo) { + try { + foreach ($group in @(Get-GroupMemberships -ObjectId $userInfo.Id -ObjectType User)) { + if (-not $group.id) { continue } + $key = "$($group.id)" + if (-not $membershipSources.ContainsKey($key)) { + $membershipSources[$key] = [PSCustomObject]@{ Name = $group.displayName; Sources = [System.Collections.Generic.List[string]]::new() } + } + if (-not $membershipSources[$key].Sources.Contains('User')) { [void]$membershipSources[$key].Sources.Add('User') } + } + } + catch { + $userMembershipKnown = $false + Write-Warning "User transitive group membership is incomplete: $($_.Exception.Message)" + } + } + if ($managedDevice) { + if ($directoryDevice) { + try { + foreach ($group in @(Get-GroupMemberships -ObjectId $directoryDevice.id -ObjectType Device)) { + if (-not $group.id) { continue } + $key = "$($group.id)" + if (-not $membershipSources.ContainsKey($key)) { + $membershipSources[$key] = [PSCustomObject]@{ Name = $group.displayName; Sources = [System.Collections.Generic.List[string]]::new() } + } + if (-not $membershipSources[$key].Sources.Contains('Device')) { [void]$membershipSources[$key].Sources.Add('Device') } + } + } + catch { + $deviceMembershipKnown = $false + Write-Warning "Device transitive group membership is incomplete: $($_.Exception.Message)" + } + } + else { $deviceMembershipKnown = $false } + } + + $subjectType = if ($userInfo -and $managedDevice) { 'UserDevice' } elseif ($userInfo) { 'User' } else { 'Device' } + $subjectId = if ($userInfo -and $managedDevice) { "$($userInfo.Id)|$($managedDevice.id)" } + elseif ($userInfo) { "$($userInfo.Id)" } else { "$($managedDevice.id)" } + $subjectName = if ($userInfo -and $managedDevice) { "$($userInfo.UserPrincipalName) on $($managedDevice.deviceName)" } + elseif ($userInfo) { "$($userInfo.UserPrincipalName)" } else { "$($managedDevice.deviceName)" } + + $effectiveRecords = [System.Collections.Generic.List[object]]::new() + $categories = Get-IntuneCategoryDefinition -Audience Effective + $entityCache = @{} + $processEntity = { + param($context) + + $assignmentSets = if ($context.Category.Id -eq 'Applications' -and $context.Assignments.Count -gt 0) { + @($context.Assignments | Group-Object { if ([string]::IsNullOrWhiteSpace("$($_.Intent)")) { 'none' } else { "$($_.Intent)".ToLowerInvariant() } } | + ForEach-Object { [PSCustomObject]@{ Intent = $_.Name; Assignments = @($_.Group) } }) + } + else { + @([PSCustomObject]@{ Intent = $null; Assignments = @($context.Assignments) }) + } + + foreach ($assignmentSet in $assignmentSets) { + $effectiveCategory = $context.Category.PSObject.Copy() + if ($context.Category.Id -eq 'Applications') { + $effectiveCategory.ExportCategory = switch ($assignmentSet.Intent) { + 'required' { 'Required App' } + 'available' { 'Available App' } + 'uninstall' { 'Uninstall App' } + default { 'Application' } + } + } + $record = Resolve-IACEffectiveAssignment -Category $effectiveCategory -Entity $context.Entity ` + -Assignments $assignmentSet.Assignments -MembershipSources $membershipSources ` + -HasUser:($null -ne $userInfo) -HasDevice:($null -ne $managedDevice) ` + -UserMembershipKnown $userMembershipKnown -DeviceMembershipKnown $deviceMembershipKnown ` + -ManagedDevice $managedDevice -SubjectType $subjectType -SubjectId $subjectId -SubjectName $subjectName + [void]$effectiveRecords.Add($record) + } + } + + $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity ` + -EntityCache $entityCache -ShowProgress -ProgressVerb 'Evaluating' + if ($scan.Errors.Count -gt 0) { + foreach ($scanError in $scan.Errors) { + $failedCategory = $categories | Where-Object Id -eq $scanError.CategoryId | Select-Object -First 1 + $categoryName = if ($failedCategory.ExportCategory) { $failedCategory.ExportCategory } + elseif ($scanError.DisplayName) { $scanError.DisplayName } + else { $scanError.CategoryId } + $failureReason = [PSCustomObject][ordered]@{ + Sequence = 1 + Code = 'Scan.CategoryFailed' + FilterCode = $null + Outcome = 'Unknown' + AssignmentId = $null + AssignmentMode = 'Unknown' + TargetType = 'Unknown' + TargetId = $null + MembershipSources = @() + TargetResult = 'Unknown' + FilterId = $null + FilterMode = $null + FilterResult = 'Unknown' + Message = "$($scanError.Message)" + } + $failureRecord = New-IACAssignmentRecord ` + -CategoryId "$($scanError.CategoryId)" -Category "$categoryName" ` + -PolicyId '' -PolicyName '[Category scan failed]' ` + -Platform $(if ($failedCategory.Platform) { $failedCategory.Platform } else { 'Unknown' }) ` + -AssignmentMode Unknown -TargetType Unknown -EffectiveState Unknown ` + -ReasonChain @($failureReason) -SubjectType $subjectType -SubjectId $subjectId -SubjectName $subjectName ` + -AssignmentReason "$($scanError.Message)" -Source 'Get-IntuneEffectiveAssignment' + [void]$effectiveRecords.Add($failureRecord) + Write-Warning "Category '$($scanError.CategoryId)' failed: $($scanError.Message)" + } + } + + if (-not $PassThru) { + if ($effectiveRecords.Count -gt 0) { + $table = $effectiveRecords | + Select-Object PolicyName, Category, EffectiveState, AssignmentMode, TargetType, TargetName | + Format-Table -AutoSize | + Out-String + Write-Host $table + } + foreach ($state in @('Included', 'Excluded', 'Unknown', 'NotTargeted')) { + $count = @($effectiveRecords | Where-Object EffectiveState -eq $state).Count + Write-Host "$state`: $count" + } + Write-Host 'Targeting analysis is not proof of delivery, applicability, installation, execution, compliance, or device check-in.' -ForegroundColor Yellow + } + else { + Write-Verbose 'Targeting analysis is not proof of delivery, applicability, installation, execution, compliance, or device check-in.' + } + + if ($ExportToCSV -or -not [string]::IsNullOrWhiteSpace($ExportPath)) { + $csvPath = if ([string]::IsNullOrWhiteSpace($ExportPath)) { + Join-Path (Get-Location) 'IntuneEffectiveAssignments.csv' + } + else { $ExportPath } + $parentPath = Split-Path -Parent $csvPath + if ($parentPath -and -not (Test-Path $parentPath)) { New-Item -ItemType Directory -Path $parentPath -Force | Out-Null } + + $csvRows = foreach ($record in $effectiveRecords) { + [PSCustomObject][ordered]@{ + SubjectType = ConvertTo-IACCsvSafeValue $record.SubjectType + SubjectId = ConvertTo-IACCsvSafeValue $record.SubjectId + SubjectName = ConvertTo-IACCsvSafeValue $record.SubjectName + CategoryId = ConvertTo-IACCsvSafeValue $record.CategoryId + Category = ConvertTo-IACCsvSafeValue $record.Category + PolicyName = ConvertTo-IACCsvSafeValue $record.PolicyName + PolicyId = ConvertTo-IACCsvSafeValue $record.PolicyId + Platform = ConvertTo-IACCsvSafeValue $record.Platform + Intent = ConvertTo-IACCsvSafeValue $record.Intent + EffectiveState = $record.EffectiveState + AssignmentMode = $record.AssignmentMode + TargetType = $record.TargetType + TargetId = ConvertTo-IACCsvSafeValue $record.TargetId + TargetName = ConvertTo-IACCsvSafeValue $record.TargetName + FilterId = ConvertTo-IACCsvSafeValue $record.FilterId + FilterName = ConvertTo-IACCsvSafeValue $record.FilterName + FilterMode = $record.FilterMode + AssignmentReason = ConvertTo-IACCsvSafeValue $record.AssignmentReason + DecisionCode = if ($record.ReasonChain.Count -gt 0) { $record.ReasonChain[-1].Code } else { $null } + ReasonChain = ConvertTo-IACCsvSafeValue (ConvertTo-Json -InputObject @($record.ReasonChain) -Depth 6 -Compress -AsArray) + } + } + $csvRows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8 + if ($PassThru) { Write-Verbose "Results exported to $csvPath" } + else { Write-Host "Results exported to $csvPath" -ForegroundColor Green } + } + + if ($PassThru) { $effectiveRecords } +} diff --git a/README.md b/README.md index 330fb81..b3b6100 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ IntuneAssignmentChecker - ๐Ÿ“ฑ View all 'All User' and 'All Device' assignments - ๐ŸŽฏ See Intune assignment filters (name and Include/Exclude type) inline on every assignment, in the console, CSV exports, and HTML reports - ๐Ÿ›ก๏ธ Safely test managed-device assignment-filter rules locally with `Test-IntuneAssignmentFilter` and tri-state `Match`, `NotMatch`, or `Unknown` results; tenant rule text is never executed +- ๐Ÿงญ Explain effective targeting for a user, managed device, or both with exclusion precedence, transitive group membership, assignment filters, and machine-readable reason chains - ๐Ÿ” Support for certificate-based and client secret authentication - ๐Ÿ”„ Version check on connect with an update notice when a newer PSGallery release is available - ๐Ÿ“Š Detailed reporting of Configuration Profiles, Compliance Policies, and Applications @@ -395,12 +396,21 @@ Test-IntuneAssignmentFilter -DeviceName 'Laptop123' -FilterId '' -Fil # Or evaluate an ad hoc managed-device rule without executing it as PowerShell Test-IntuneAssignmentFilter -DeviceName 'Laptop123' -Rule '(device.deviceOwnership -eq "Corporate")' + +# Explain whether every discovered policy and assigned app targets a user on a managed device +Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' -DeviceName 'Laptop123' + +# Export the explanation and retain typed records for automation +$effective = Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' ` + -DeviceName 'Laptop123' -PassThru -ExportPath 'C:\Temp\EffectiveAssignments.csv' +$effective | Where-Object EffectiveState -in 'Excluded', 'Unknown' ``` `Get-IntuneUserAssignment`, `Get-IntuneGroupAssignment`, `Get-IntuneDeviceAssignment`, `Get-IntuneAllPolicies`, `Get-IntuneAllUsersAssignment`, `Get-IntuneAllDevicesAssignment`, -`Get-IntuneUnassignedPolicy`, and `Search-IntunePolicy` support `-PassThru`. +`Get-IntuneUnassignedPolicy`, `Get-IntuneEffectiveAssignment`, and +`Search-IntunePolicy` support `-PassThru`. Using it also suppresses the interactive CSV-export prompt. Each object has the type name `IntuneAssignmentChecker.AssignmentRecord` and schema version `1`. The stable contract includes tenant and subject metadata, policy/category/platform, scope @@ -415,8 +425,29 @@ used for their console and CSV views, while the HTML report keeps its purpose-bu flat reporting schema. Treat `CategoryId` as the stable machine key; `Category` is a presentation label and can vary where a cmdlet distinguishes app intents or uses search-specific wording. `Get-IntuneUserDeviceAssignment` keeps its established -combined user/device presentation; the v4.4 effective-targeting cmdlet introduced -in issue #140 adds a canonical explanation model for those results. +combined user/device presentation. `Get-IntuneEffectiveAssignment` adds a +canonical explanation model whose `EffectiveState` is `Included`, `Excluded`, +`NotTargeted`, or `Unknown` and whose `ReasonChain` records every evaluated +assignment and the final precedence decision. It unions user and device transitive +group memberships, evaluates All Users and All Devices, gives active or unresolved +exclusions precedence, and applies locally evaluated device assignment filters. +When an inclusion and exclusion match different user/device targeting dimensions, +the result is conservatively `Unknown` because that mixed targeting design cannot +be inferred safely. An exclusion without any matching or unresolved inclusion is +`NotTargeted`, not `Excluded`. For combined checks, `SubjectType` is `UserDevice` +and `SubjectId` is the user object ID and managed-device ID joined with `|`; use +`ReasonChain[*].MembershipSources` to distinguish user-side and device-side group +matches. Application analysis covers apps that have at least one tenant assignment; +unassigned apps remain available through `Get-IntuneUnassignedPolicy`. +This is targeting analysis: it does not prove delivery, platform applicability, +installation, execution, compliance, or device check-in. + +If a non-optional workload cannot be scanned, `-PassThru` and CSV output include +one typed `Unknown` record with an empty `PolicyId`, `PolicyName` set to +`[Category scan failed]`, and reason code `Scan.CategoryFailed`. This prevents +automation from mistaking an unreadable category for a category with no matching +assignments. CSV rows also expose the final `DecisionCode`; inspect the full JSON +`ReasonChain` for every target, filter, and precedence decision. `Get-IntuneGroupAssignment` CSV/Excel exports include `GroupId`, `GroupName`, `GroupType`, `MembershipType`, and `GroupMail` on every group and policy/app @@ -449,6 +480,7 @@ Available cmdlets: | `Get-IntuneUserAssignment` | Check assignments for specific users | | `Get-IntuneGroupAssignment` | Check assignments for specific groups | | `Get-IntuneDeviceAssignment` | Check assignments for specific devices | +| `Get-IntuneEffectiveAssignment` | Explain effective targeting for a user, managed device, or both | | `Get-IntuneAllPolicies` | Show all policies and their assignments | | `Get-IntuneAllUsersAssignment` | Show all 'All Users' assignments | | `Get-IntuneAllDevicesAssignment` | Show all 'All Devices' assignments | diff --git a/Tests/Unit/AssignmentRecord.Tests.ps1 b/Tests/Unit/AssignmentRecord.Tests.ps1 index e6a51de..0b455a7 100644 --- a/Tests/Unit/AssignmentRecord.Tests.ps1 +++ b/Tests/Unit/AssignmentRecord.Tests.ps1 @@ -38,8 +38,11 @@ Describe 'IntuneAssignmentChecker.AssignmentRecord' { 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', + 'EffectiveState', 'ReasonChain', 'AssignmentReason', 'Source' ) + $record.EffectiveState | Should -BeNullOrEmpty + $record.ReasonChain | Should -BeNullOrEmpty } It 'allows unnamed or partial Graph entities without aborting the pipeline' { diff --git a/Tests/Unit/CategoryScan.Tests.ps1 b/Tests/Unit/CategoryScan.Tests.ps1 index a509e35..9770fac 100644 --- a/Tests/Unit/CategoryScan.Tests.ps1 +++ b/Tests/Unit/CategoryScan.Tests.ps1 @@ -11,6 +11,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') . (Join-Path $modulePrivate 'Add-ExportData.ps1') @@ -198,6 +199,7 @@ Describe 'Invoke-IntuneCategoryScan' { $script:processedIds | Should -Be @('comp-1') $result.Errors.Count | Should -Be 1 $result.Errors[0].CategoryId | Should -Be 'DeviceConfigurations' + $result.Errors[0].DisplayName | Should -BeExactly 'Device Configurations' $result.Errors[0].Message | Should -Match 'boom' Should -Invoke Write-Error -Exactly 1 } @@ -586,6 +588,13 @@ Describe 'Get-IntuneCategoryDefinition' { $categories.Id | Should -Contain 'ShellScripts' } + It 'returns a complete 23-category inventory for Effective targeting' { + $categories = Get-IntuneCategoryDefinition -Audience Effective + @($categories).Count | Should -Be 23 + @($categories | Where-Object { $_.BucketOnly }).Count | Should -Be 0 + ($categories | Where-Object Id -eq SettingsCatalog).EntityFilter | Should -Not -BeNullOrEmpty + } + It 'registers every Windows Update workload as an optional shared entity category' { $expected = [ordered]@{ WindowsFeatureUpdates = 'windowsFeatureUpdateProfiles' @@ -593,7 +602,7 @@ Describe 'Get-IntuneCategoryDefinition' { WindowsDriverUpdates = 'windowsDriverUpdateProfiles' WindowsQualityUpdatePolicies = 'windowsQualityUpdatePolicies' } - foreach ($audience in @('UserContext', 'DeviceContext', 'GroupContext', 'AllPolicies', 'Search', 'Compare')) { + foreach ($audience in @('UserContext', 'DeviceContext', 'GroupContext', 'AllPolicies', 'Search', 'Compare', 'Effective')) { $categories = Get-IntuneCategoryDefinition -Audience $audience foreach ($id in $expected.Keys) { $category = $categories | Where-Object Id -eq $id diff --git a/Tests/Unit/CompareGroupAssignment.Tests.ps1 b/Tests/Unit/CompareGroupAssignment.Tests.ps1 index feb6db1..23681f3 100644 --- a/Tests/Unit/CompareGroupAssignment.Tests.ps1 +++ b/Tests/Unit/CompareGroupAssignment.Tests.ps1 @@ -13,6 +13,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Format-AssignmentFilter.ps1') . (Join-Path $moduleRoot 'Public/Compare-IntuneGroupAssignment.ps1') diff --git a/Tests/Unit/DeviceAssignment.Tests.ps1 b/Tests/Unit/DeviceAssignment.Tests.ps1 index 757a2d6..929ff1d 100644 --- a/Tests/Unit/DeviceAssignment.Tests.ps1 +++ b/Tests/Unit/DeviceAssignment.Tests.ps1 @@ -19,6 +19,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Add-ExportData.ps1') diff --git a/Tests/Unit/EffectiveAssignment.Tests.ps1 b/Tests/Unit/EffectiveAssignment.Tests.ps1 new file mode 100644 index 0000000..4e29cfd --- /dev/null +++ b/Tests/Unit/EffectiveAssignment.Tests.ps1 @@ -0,0 +1,488 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $modulePrivate = Join-Path $moduleRoot 'Private' + + . (Join-Path $modulePrivate 'Get-PolicyPlatform.ps1') + . (Join-Path $modulePrivate 'Get-ScopeTagNames.ps1') + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'ConvertTo-IACCsvSafeValue.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') + . (Join-Path $modulePrivate 'Test-IACAssignmentFilter.ps1') + . (Join-Path $modulePrivate 'Resolve-IACEffectiveAssignment.ps1') + . (Join-Path $moduleRoot 'Public/Get-IntuneEffectiveAssignment.ps1') + + $script:ScopeTagLookup = @{} + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + $script:GraphEndpoint = 'https://graph.test' + + function Get-UserInfo { param([string]$UserPrincipalName) } + function Get-IACManagedDevice { param([string]$Identity) } + function Get-IACDirectoryDevice { param([string]$AzureADDeviceId) } + function Get-GroupMemberships { param([string]$ObjectId, [string]$ObjectType) @() } + function Get-AssignmentFilterLookup { @{} } + function Get-IntuneCategoryDefinition { param([string]$Audience) @() } + function Invoke-IntuneCategoryScan { + param([object[]]$Categories, [scriptblock]$ProcessEntity, [hashtable]$EntityCache, [switch]$ShowProgress, [string]$ProgressVerb) + [PSCustomObject]@{ Errors = @() } + } + + function New-EffectiveTestCategory { + param([string]$Id = 'DeviceConfigurations', [string]$ExportCategory = 'Device Configuration') + [PSCustomObject]@{ + Id = $Id + ExportCategory = $ExportCategory + DisplayName = $ExportCategory + Platform = $null + } + } + + function New-EffectiveTestEntity { + param([string]$Id = 'policy-1', [string]$Name = 'Policy One') + [PSCustomObject]@{ + id = $Id + displayName = $Name + roleScopeTagIds = @() + '@odata.type' = '#microsoft.graph.windows10GeneralConfiguration' + } + } + + function New-EffectiveTestAssignment { + param( + [string]$Id = 'assignment-1', + [ValidateSet('Include', 'Exclude')][string]$Mode = 'Include', + [ValidateSet('AllUsers', 'AllDevices', 'Group')][string]$TargetType = 'Group', + [string]$TargetId = 'group-1', + [string]$Intent, + [string]$FilterId, + [string]$FilterType + ) + [PSCustomObject]@{ + AssignmentId = $Id + Reason = if ($Mode -eq 'Exclude') { 'Group Exclusion' } else { 'Group Assignment' } + AssignmentMode = $Mode + TargetType = $TargetType + TargetId = $TargetId + GroupId = $TargetId + Intent = $Intent + FilterId = $FilterId + FilterType = $FilterType + } + } + + function Invoke-EffectiveTestResolution { + param( + [object[]]$Assignments, + [hashtable]$MembershipSources = @{}, + [bool]$HasUser = $true, + [bool]$HasDevice = $false, + [bool]$UserMembershipKnown = $true, + [bool]$DeviceMembershipKnown = $true, + [AllowNull()]$ManagedDevice = $null + ) + Resolve-IACEffectiveAssignment -Category (New-EffectiveTestCategory) -Entity (New-EffectiveTestEntity) ` + -Assignments $Assignments -MembershipSources $MembershipSources ` + -HasUser:$HasUser -HasDevice:$HasDevice ` + -UserMembershipKnown $UserMembershipKnown -DeviceMembershipKnown $DeviceMembershipKnown ` + -ManagedDevice $ManagedDevice -SubjectType $(if ($HasUser -and $HasDevice) { 'UserDevice' } elseif ($HasUser) { 'User' } else { 'Device' }) ` + -SubjectId 'subject-1' -SubjectName 'Subject One' + } +} + +Describe 'Resolve-IACEffectiveAssignment' { + BeforeEach { + $script:AssignmentFilterLookup = @{} + $script:windowsDevice = [PSCustomObject]@{ + id = 'managed-1' + deviceName = 'WIN-01' + operatingSystem = 'Windows' + managedDeviceOwnerType = 'company' + model = 'Surface Pro 9' + } + } + + It 'includes All Users only when a user subject is present' { + $assignment = New-EffectiveTestAssignment -TargetType AllUsers -TargetId $null + + (Invoke-EffectiveTestResolution -Assignments @($assignment) -HasUser $true).EffectiveState | Should -BeExactly Included + (Invoke-EffectiveTestResolution -Assignments @($assignment) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice).EffectiveState | Should -BeExactly NotTargeted + } + + It 'includes All Devices only when a managed-device subject is present' { + $assignment = New-EffectiveTestAssignment -TargetType AllDevices -TargetId $null + + (Invoke-EffectiveTestResolution -Assignments @($assignment) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice).EffectiveState | Should -BeExactly Included + (Invoke-EffectiveTestResolution -Assignments @($assignment) -HasUser $true).EffectiveState | Should -BeExactly NotTargeted + } + + It 'records both membership sources for a shared transitive group' { + $sources = @{ + 'group-shared' = [PSCustomObject]@{ Name = 'Shared'; Sources = @('User', 'Device') } + } + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetId 'group-shared' + ) -MembershipSources $sources -HasUser $true -HasDevice $true -ManagedDevice $script:windowsDevice + + $result.EffectiveState | Should -BeExactly Included + $result.TargetName | Should -BeExactly Shared + $result.ReasonChain[0].MembershipSources | Should -Be @('User', 'Device') + } + + It 'honors a matching exclusion over a matching inclusion' { + $sources = @{ 'excluded-group' = [PSCustomObject]@{ Name = 'Excluded'; Sources = @('User') } } + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -Id include-all -TargetType AllUsers -TargetId $null + New-EffectiveTestAssignment -Id exclude-group -Mode Exclude -TargetId excluded-group + ) -MembershipSources $sources + + $result.EffectiveState | Should -BeExactly Excluded + $result.AssignmentId | Should -BeExactly exclude-group + $result.ReasonChain[-1].Code | Should -BeExactly Decision.Excluded + } + + It 'returns NotTargeted when only an exclusion matches' { + $sources = @{ 'excluded-group' = [PSCustomObject]@{ Name = 'Excluded'; Sources = @('User') } } + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -Id exclude-only -Mode Exclude -TargetId excluded-group + ) -MembershipSources $sources + + $result.EffectiveState | Should -BeExactly NotTargeted + $result.TargetType | Should -BeExactly None + $result.AssignmentReason | Should -BeExactly 'No Matching Assignment' + $result.ReasonChain[-1].Code | Should -BeExactly Decision.NotTargeted + } + + It 'returns Unknown when user and device inclusion and exclusion dimensions conflict' { + $sources = @{ 'device-exclusion' = [PSCustomObject]@{ Name = 'Device Exclusion'; Sources = @('Device') } } + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -Id include-user -TargetType AllUsers -TargetId $null + New-EffectiveTestAssignment -Id exclude-device -Mode Exclude -TargetId device-exclusion + ) -MembershipSources $sources -HasUser $true -HasDevice $true -ManagedDevice $script:windowsDevice + + $result.EffectiveState | Should -BeExactly Unknown + $result.ReasonChain[-1].Code | Should -BeExactly Decision.CrossDimensionUnknown + } + + It 'treats a nested group returned by transitive membership as included' { + $sources = @{ 'nested-parent' = [PSCustomObject]@{ Name = 'Nested Parent'; Sources = @('User') } } + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetId nested-parent + ) -MembershipSources $sources + + $result.EffectiveState | Should -BeExactly Included + $result.ReasonChain[0].Code | Should -BeExactly Target.TransitiveGroupMembership + } + + It 'applies include and exclude assignment-filter semantics' { + $script:AssignmentFilterLookup = @{ + corporate = [PSCustomObject]@{ + Id = 'corporate'; Name = 'Corporate'; Platform = 'windows10AndLater' + Rule = '(device.deviceOwnership -eq "Corporate")'; AssignmentFilterManagementType = 'devices' + } + } + + $includeResult = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetType AllDevices -TargetId $null -FilterId corporate -FilterType include + ) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice + $excludeResult = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetType AllDevices -TargetId $null -FilterId corporate -FilterType exclude + ) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice + + $includeResult.EffectiveState | Should -BeExactly Included + $includeResult.ReasonChain[0].FilterResult | Should -BeExactly Match + $excludeResult.EffectiveState | Should -BeExactly NotTargeted + $excludeResult.ReasonChain[0].FilterResult | Should -BeExactly NotMatch + } + + It 'treats zero-GUID and none filter metadata as unfiltered' { + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetType AllDevices -TargetId $null ` + -FilterId '00000000-0000-0000-0000-000000000000' -FilterType none + ) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice + + $result.EffectiveState | Should -BeExactly Included + $result.ReasonChain[0].FilterCode | Should -BeExactly Filter.None + } + + It 'accepts every target, mode, and filter token emitted by the real Graph assignment normalizer' { + $specs = @( + @{ Type = '#microsoft.graph.allLicensedUsersAssignmentTarget'; TargetType = 'AllUsers'; Mode = 'Include' } + @{ Type = '#microsoft.graph.allDevicesAssignmentTarget'; TargetType = 'AllDevices'; Mode = 'Include' } + @{ Type = '#microsoft.graph.groupAssignmentTarget'; TargetType = 'Group'; Mode = 'Include'; GroupId = 'group-include' } + @{ Type = '#microsoft.graph.exclusionGroupAssignmentTarget'; TargetType = 'Group'; Mode = 'Exclude'; GroupId = 'group-exclude' } + ) + $normalizedByType = @{} + foreach ($spec in $specs) { + $target = [PSCustomObject]@{ + '@odata.type' = $spec.Type + groupId = $spec.GroupId + deviceAndAppManagementAssignmentFilterId = '00000000-0000-0000-0000-000000000000' + deviceAndAppManagementAssignmentFilterType = 'none' + } + $normalized = ConvertTo-IACNormalizedAssignment -Assignment ([PSCustomObject]@{ + id = $spec.Type; target = $target + }) + $normalizedByType[$spec.Type] = $normalized + $normalized.TargetType | Should -BeExactly $spec.TargetType + $normalized.AssignmentMode | Should -BeExactly $spec.Mode + $normalized.FilterId | Should -BeNullOrEmpty + } + + $includeFilter = ConvertTo-IACNormalizedAssignment -Assignment ([PSCustomObject]@{ + id = 'filter-include' + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'group-include' + deviceAndAppManagementAssignmentFilterId = 'filter-1' + deviceAndAppManagementAssignmentFilterType = 'include' + } + }) + $excludeFilter = ConvertTo-IACNormalizedAssignment -Assignment ([PSCustomObject]@{ + id = 'filter-exclude' + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'group-include' + deviceAndAppManagementAssignmentFilterId = 'filter-2' + deviceAndAppManagementAssignmentFilterType = 'exclude' + } + }) + $includeFilter.FilterType | Should -BeExactly include + $excludeFilter.FilterType | Should -BeExactly exclude + + $sources = @{ 'group-exclude' = [PSCustomObject]@{ Name = 'Excluded'; Sources = @('User') } } + $result = Invoke-EffectiveTestResolution -Assignments @( + $normalizedByType['#microsoft.graph.allLicensedUsersAssignmentTarget'] + $normalizedByType['#microsoft.graph.exclusionGroupAssignmentTarget'] + ) -MembershipSources $sources + $result.EffectiveState | Should -BeExactly Excluded + } + + It 'returns Unknown for an unknown assignment mode instead of dropping the candidate' { + $assignment = New-EffectiveTestAssignment -TargetType AllUsers -TargetId $null + $assignment.AssignmentMode = 'Unknown' + + $result = Invoke-EffectiveTestResolution -Assignments @($assignment) + + $result.EffectiveState | Should -BeExactly Unknown + $result.ReasonChain[-1].Code | Should -BeExactly Decision.UnknownAssignmentMode + } + + It 'returns Unknown when a real filter id has no filter mode' { + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetType AllDevices -TargetId $null -FilterId real-filter -FilterType '' + ) -HasUser $false -HasDevice $true -ManagedDevice $script:windowsDevice + + $result.EffectiveState | Should -BeExactly Unknown + $result.ReasonChain[0].FilterCode | Should -BeExactly Filter.UnsupportedMode + } + + It 'returns Unknown when membership or a filter cannot be evaluated safely' { + $membershipUnknown = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetId unknown-group + ) -UserMembershipKnown $false + $filterUnknown = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -TargetType AllUsers -TargetId $null -FilterId missing-filter -FilterType include + ) + + $membershipUnknown.EffectiveState | Should -BeExactly Unknown + $membershipUnknown.ReasonChain[0].Code | Should -BeExactly Target.GroupMembershipUnknown + $filterUnknown.EffectiveState | Should -BeExactly Unknown + $filterUnknown.ReasonChain[0].FilterResult | Should -BeExactly Unknown + $filterUnknown.ReasonChain[0].FilterCode | Should -BeExactly Filter.NoDevice + } + + It 'lets a possible exclusion make an otherwise active inclusion Unknown' { + $result = Invoke-EffectiveTestResolution -Assignments @( + New-EffectiveTestAssignment -Id include-all -TargetType AllUsers -TargetId $null + New-EffectiveTestAssignment -Id maybe-excluded -Mode Exclude -TargetId unknown-group + ) -UserMembershipKnown $false + + $result.EffectiveState | Should -BeExactly Unknown + $result.ReasonChain[-1].Code | Should -BeExactly Decision.UnresolvedExclusion + } + + It 'returns NotTargeted with a reason chain when a policy has no assignments' { + $result = Invoke-EffectiveTestResolution -Assignments @() + + $result.EffectiveState | Should -BeExactly NotTargeted + $result.AssignmentMode | Should -BeExactly None + $result.ReasonChain[-1].Message | Should -BeExactly 'The policy has no assignments.' + } +} + +Describe 'Get-IntuneEffectiveAssignment orchestration' { + BeforeEach { + $script:AssignmentFilterLookup = @{} + $script:effectiveContexts = @( + [PSCustomObject]@{ + Category = New-EffectiveTestCategory + Entity = New-EffectiveTestEntity + Assignments = @(New-EffectiveTestAssignment -TargetId shared-group) + } + ) + Mock Write-Host {} + Mock Write-Warning {} + Mock Get-UserInfo { + @{ Success = $true; Id = 'user-1'; UserPrincipalName = 'user@contoso.com' } + } + Mock Get-IACManagedDevice { + [PSCustomObject]@{ + Success = $true + Device = [PSCustomObject]@{ + id = 'managed-1'; deviceName = 'WIN-01'; azureADDeviceId = 'aad-device-1' + operatingSystem = 'Windows'; managedDeviceOwnerType = 'company' + } + Reason = $null + } + } + Mock Get-IACDirectoryDevice { + [PSCustomObject]@{ Success = $true; Device = [PSCustomObject]@{ id = 'directory-1'; deviceId = 'aad-device-1' }; Reason = $null } + } + Mock Get-GroupMemberships { + @([PSCustomObject]@{ id = 'shared-group'; displayName = 'Shared Group' }) + } + Mock Get-IntuneCategoryDefinition { @(New-EffectiveTestCategory) } + $script:effectiveScanErrors = @() + Mock Invoke-IntuneCategoryScan { + foreach ($context in $script:effectiveContexts) { & $ProcessEntity $context } + [PSCustomObject]@{ Errors = @($script:effectiveScanErrors) } + } + } + + It 'unions user and device transitive membership without leaking collection indices' { + $result = @(Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -DeviceName WIN-01 -PassThru) + + $result.Count | Should -Be 1 + $result[0].PSObject.TypeNames | Should -Contain IntuneAssignmentChecker.AssignmentRecord + $result[0].SubjectType | Should -BeExactly UserDevice + $result[0].EffectiveState | Should -BeExactly Included + $result[0].ReasonChain[0].MembershipSources | Should -Be @('User', 'Device') + Should -Invoke Write-Host -Exactly 0 + Should -Invoke Get-GroupMemberships -Exactly 1 -ParameterFilter { $ObjectType -eq 'User' -and $ObjectId -eq 'user-1' } + Should -Invoke Get-GroupMemberships -Exactly 1 -ParameterFilter { $ObjectType -eq 'Device' -and $ObjectId -eq 'directory-1' } + } + + It 'marks group targeting Unknown when directory-device mapping is unavailable' { + Mock Get-IACDirectoryDevice { + [PSCustomObject]@{ Success = $false; Device = $null; Reason = 'not mapped' } + } + + $result = @(Get-IntuneEffectiveAssignment -DeviceName WIN-01 -PassThru) + + $result.Count | Should -Be 1 + $result[0].EffectiveState | Should -BeExactly Unknown + Should -Invoke Write-Warning -ParameterFilter { $Message -eq 'not mapped' } + } + + It 'splits application results by intent' { + $script:effectiveContexts = @( + [PSCustomObject]@{ + Category = New-EffectiveTestCategory -Id Applications -ExportCategory Application + Entity = New-EffectiveTestEntity -Id app-1 -Name 'Company Portal' + Assignments = @( + New-EffectiveTestAssignment -Id required -TargetType AllUsers -TargetId $null -Intent required + New-EffectiveTestAssignment -Id available -TargetType AllUsers -TargetId $null -Intent available + ) + } + ) + + $result = @(Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -PassThru) + + $result.Count | Should -Be 2 + $result.Category | Should -Contain 'Required App' + $result.Category | Should -Contain 'Available App' + } + + It 'exports formula-safe CSV with a JSON reason chain' { + $script:effectiveContexts[0].Entity.displayName = '=danger' + $path = Join-Path $TestDrive 'effective.csv' + + $result = @(Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -PassThru -ExportPath $path) + $csv = Import-Csv $path + + $result.Count | Should -Be 1 + $csv.Count | Should -Be 1 + $csv[0].PolicyName | Should -BeExactly "'=danger" + $csv[0].CategoryId | Should -BeExactly DeviceConfigurations + $csv[0].TargetName | Should -BeExactly 'Shared Group' + $csv[0].EffectiveState | Should -BeExactly Included + $csv[0].DecisionCode | Should -BeExactly Decision.Included + $csv[0].ReasonChain | Should -Match '^\[' + ($csv[0].ReasonChain | ConvertFrom-Json)[-1].Code | Should -BeExactly Decision.Included + } + + It 'keeps a one-entry no-assignment reason chain as a JSON array' { + $script:effectiveContexts[0].Assignments = @() + $path = Join-Path $TestDrive 'empty-assignment.csv' + + $null = Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -ExportPath $path + $csv = Import-Csv $path + + $csv[0].ReasonChain | Should -Match '^\[' + @($csv[0].ReasonChain | ConvertFrom-Json).Count | Should -Be 1 + ($csv[0].ReasonChain | ConvertFrom-Json)[0].Code | Should -BeExactly Decision.NotTargeted + } + + It 'returns a machine-readable Unknown record for every failed category' { + $script:effectiveContexts = @() + $script:effectiveScanErrors = @([PSCustomObject]@{ + CategoryId = 'DeviceConfigurations' + DisplayName = 'Device Configurations' + Message = 'Graph returned 403 Forbidden' + }) + + $result = @(Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -PassThru) + + $result.Count | Should -Be 1 + $result[0].PSObject.TypeNames | Should -Contain IntuneAssignmentChecker.AssignmentRecord + $result[0].EffectiveState | Should -BeExactly Unknown + $result[0].ReasonChain[0].Code | Should -BeExactly Scan.CategoryFailed + Should -Invoke Write-Warning -ParameterFilter { $Message -match 'DeviceConfigurations.*403 Forbidden' } + } + + It 'rejects invalid export paths before resolving subjects or scanning the tenant' { + $errors = @() + Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -ExportPath './wrong.json' ` + -ErrorVariable +errors -ErrorAction SilentlyContinue + + $errors.Exception.Message | Should -Contain 'ExportPath must be a .csv file.' + Should -Invoke Get-UserInfo -Exactly 0 + Should -Invoke Invoke-IntuneCategoryScan -Exactly 0 + } + + It 'requires at least one subject before making Graph calls' { + $errors = @() + Get-IntuneEffectiveAssignment -ErrorVariable +errors -ErrorAction SilentlyContinue + + $errors.Exception.Message | Should -Contain 'Supply -UserPrincipalName, -DeviceName, or both.' + Should -Invoke Get-UserInfo -Exactly 0 + Should -Invoke Get-IACManagedDevice -Exactly 0 + } + + It 'requires a module connection before resolving a subject' { + $savedEndpoint = $script:GraphEndpoint + $script:GraphEndpoint = $null + try { + $errors = @() + Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com ` + -ErrorVariable +errors -ErrorAction SilentlyContinue + + $errors.Exception.Message | Should -Contain 'Connect first with Connect-IntuneAssignmentChecker.' + Should -Invoke Get-UserInfo -Exactly 0 + Should -Invoke Invoke-IntuneCategoryScan -Exactly 0 + } + finally { $script:GraphEndpoint = $savedEndpoint } + } + + It 'uses the default CSV name when ExportToCSV is supplied without ExportPath' { + Push-Location $TestDrive + try { + $null = Get-IntuneEffectiveAssignment -UserPrincipalName user@contoso.com -ExportToCSV + Test-Path (Join-Path $TestDrive 'IntuneEffectiveAssignments.csv') | Should -BeTrue + } + finally { Pop-Location } + } +} diff --git a/Tests/Unit/GraphMembership.Tests.ps1 b/Tests/Unit/GraphMembership.Tests.ps1 index 86e85b8..12724e1 100644 --- a/Tests/Unit/GraphMembership.Tests.ps1 +++ b/Tests/Unit/GraphMembership.Tests.ps1 @@ -12,10 +12,74 @@ BeforeAll { } . (Join-Path $modulePrivate 'Get-TransitiveGroupMembership.ps1') + . (Join-Path $modulePrivate 'Get-GroupMemberships.ps1') + . (Join-Path $modulePrivate 'Get-IACDirectoryDevice.ps1') $script:GraphEndpoint = 'https://graph.test' } +Describe 'Get-GroupMemberships' { + BeforeEach { + Mock Invoke-IACGraphRequest { + @{ value = @([PSCustomObject]@{ id = 'group-1'; displayName = 'Group One' }) } + } + } + + It 'uses the typed beta transitive-group endpoint for a user' { + $result = @(Get-GroupMemberships -ObjectId user-1 -ObjectType User) + + $result.Count | Should -Be 1 + $result[0].id | Should -BeExactly group-1 + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/users/user-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName' -and $Method -eq 'Get' + } + } + + It 'uses the typed beta transitive-group endpoint for a directory device' { + $null = Get-GroupMemberships -ObjectId directory-1 -ObjectType Device + + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/devices/directory-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName' -and $Method -eq 'Get' + } + } +} + +Describe 'Get-IACDirectoryDevice' { + It 'maps a managed-device azureADDeviceId to the Entra directory object through beta' { + Mock Invoke-IACGraphRequest { + @{ value = @([PSCustomObject]@{ id = 'directory-1'; displayName = 'WIN-01'; deviceId = 'aad-device-1' }) } + } + + $result = Get-IACDirectoryDevice -AzureADDeviceId aad-device-1 + + $result.Success | Should -BeTrue + $result.Device.id | Should -BeExactly directory-1 + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/devices?$filter=deviceId%20eq%20%27aad-device-1%27&$select=id,displayName,deviceId' -and $Method -eq 'Get' + } + } + + It 'returns an explicit failure for zero or ambiguous mappings' { + Mock Invoke-IACGraphRequest { @{ value = @() } } + (Get-IACDirectoryDevice -AzureADDeviceId missing).Reason | Should -Match '^No Entra device maps' + + Mock Invoke-IACGraphRequest { + @{ value = @([PSCustomObject]@{ id = 'one' }, [PSCustomObject]@{ id = 'two' }) } + } + (Get-IACDirectoryDevice -AzureADDeviceId duplicate).Reason | Should -Match '^Multiple Entra devices' + } + + It 'escapes quotes before URL-encoding the OData deviceId filter' { + Mock Invoke-IACGraphRequest { @{ value = @() } } + + $null = Get-IACDirectoryDevice -AzureADDeviceId "device'id" + + Should -Invoke Invoke-IACGraphRequest -Exactly 1 -ParameterFilter { + $Uri -eq 'https://graph.test/beta/devices?$filter=deviceId%20eq%20%27device%27%27id%27&$select=id,displayName,deviceId' + } + } +} + Describe 'Get-TransitiveGroupMembership' { BeforeEach { $script:requestedUris = [System.Collections.Generic.List[string]]::new() diff --git a/Tests/Unit/GroupAssignment.Tests.ps1 b/Tests/Unit/GroupAssignment.Tests.ps1 index 36276c1..4556895 100644 --- a/Tests/Unit/GroupAssignment.Tests.ps1 +++ b/Tests/Unit/GroupAssignment.Tests.ps1 @@ -16,6 +16,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $modulePrivate 'Get-GroupAssignmentReasons.ps1') diff --git a/Tests/Unit/SearchPassThru.Tests.ps1 b/Tests/Unit/SearchPassThru.Tests.ps1 index 271898f..52251bc 100644 --- a/Tests/Unit/SearchPassThru.Tests.ps1 +++ b/Tests/Unit/SearchPassThru.Tests.ps1 @@ -6,7 +6,7 @@ BeforeAll { $private = Join-Path $moduleRoot Private foreach ($name in @( 'Get-PolicyPlatform.ps1', 'Get-ScopeTagNames.ps1', 'New-IACAssignmentRecord.ps1', - 'ConvertTo-IACAssignmentRecord.ps1', 'ConvertTo-IACNormalizedAssignment.ps1', + 'ConvertTo-IACAssignmentRecord.ps1', 'ConvertTo-IACNormalizedAssignment.ps1', 'Get-IACNoAssignmentPlaceholder.ps1', 'Select-IACAssignmentRecord.ps1', 'Format-AssignmentFilter.ps1', 'Get-Separator.ps1', 'Get-AppProtectionAssignmentUri.ps1', 'Test-ImportedAdministrativeTemplate.ps1', 'Get-IntuneCategoryDefinition.ps1', 'Invoke-IntuneCategoryScan.ps1')) { diff --git a/Tests/Unit/TestGroupMembership.Tests.ps1 b/Tests/Unit/TestGroupMembership.Tests.ps1 index c82d34e..7523297 100644 --- a/Tests/Unit/TestGroupMembership.Tests.ps1 +++ b/Tests/Unit/TestGroupMembership.Tests.ps1 @@ -19,6 +19,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Test-IntuneGroupMembership.ps1') diff --git a/Tests/Unit/TestGroupRemoval.Tests.ps1 b/Tests/Unit/TestGroupRemoval.Tests.ps1 index c7199e6..521e323 100644 --- a/Tests/Unit/TestGroupRemoval.Tests.ps1 +++ b/Tests/Unit/TestGroupRemoval.Tests.ps1 @@ -19,6 +19,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Test-IntuneGroupRemoval.ps1') diff --git a/Tests/Unit/UserAssignment.Tests.ps1 b/Tests/Unit/UserAssignment.Tests.ps1 index e9e70fd..4c05486 100644 --- a/Tests/Unit/UserAssignment.Tests.ps1 +++ b/Tests/Unit/UserAssignment.Tests.ps1 @@ -19,6 +19,7 @@ BeforeAll { . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'ConvertTo-IACNormalizedAssignment.ps1') + . (Join-Path $modulePrivate 'Get-IACNoAssignmentPlaceholder.ps1') . (Join-Path $modulePrivate 'Select-IACAssignmentRecord.ps1') . (Join-Path $modulePrivate 'Invoke-IntuneCategoryScan.ps1') . (Join-Path $moduleRoot 'Public/Get-IntuneUserAssignment.ps1') From 95fe831eb94d1594355dfe6b225fd4321882f018 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:23:34 +0200 Subject: [PATCH 6/8] Add assignment snapshots and drift comparison (#141) --- .../IntuneAssignmentChecker.psd1 | 3 + .../Private/AssignmentSnapshot.ps1 | 425 +++++++++++++++ .../Private/Invoke-IntuneCategoryScan.ps1 | 7 + .../Compare-IntuneAssignmentSnapshot.ps1 | 168 ++++++ .../Export-IntuneAssignmentSnapshot.ps1 | 235 +++++++++ README.md | 41 ++ Tests/Unit/AssignmentSnapshot.Tests.ps1 | 488 ++++++++++++++++++ Tests/Unit/CategoryScan.Tests.ps1 | 5 +- 8 files changed, 1371 insertions(+), 1 deletion(-) create mode 100644 Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Export-IntuneAssignmentSnapshot.ps1 create mode 100644 Tests/Unit/AssignmentSnapshot.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index aa3bd1d..c1f14e6 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -16,6 +16,8 @@ 'Get-IntuneDeviceAssignment' 'Get-IntuneUserDeviceAssignment' 'Get-IntuneEffectiveAssignment' + 'Export-IntuneAssignmentSnapshot' + 'Compare-IntuneAssignmentSnapshot' 'Get-IntuneAllPolicies' 'Get-IntuneAllUsersAssignment' 'Get-IntuneAllDevicesAssignment' @@ -51,6 +53,7 @@ Version 4.4.0: - Cover Windows Feature Update, Quality Update, Driver Update, and Quality Update policy assignments across shared scans, searches, comparisons, exports, and reports (issue #138). - Add Test-IntuneAssignmentFilter for safe, local tri-state evaluation of documented managed-device filter rules without executing tenant-provided text (issue #139). - Add Get-IntuneEffectiveAssignment with user/device targeting precedence, filter evaluation, machine-readable reason chains, PassThru, and CSV output (issue #140). +- Add deterministic, schema-versioned assignment snapshots and stable Added/Removed/Changed drift comparison (issue #141). Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). diff --git a/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 new file mode 100644 index 0000000..207c968 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 @@ -0,0 +1,425 @@ +function Get-IACAssignmentRecordPropertyNames { + [CmdletBinding()] + param() + + @( + 'SchemaVersion', 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', + 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', + 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', + 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', 'EffectiveState', + 'ReasonChain', 'AssignmentReason', 'Source' + ) +} + +function Get-IACOrdinalSortedUniqueString { + [CmdletBinding()] + param( + [AllowEmptyCollection()][object[]]$InputObject = @() + ) + + $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($item in @($InputObject)) { + if ($null -eq $item) { continue } + [void]$set.Add("$item") + } + $list = [System.Collections.Generic.List[string]]::new() + foreach ($item in $set) { [void]$list.Add($item) } + $list.Sort([System.StringComparer]::Ordinal) + return $list.ToArray() +} + +function Get-IACOrdinalSortedObject { + [CmdletBinding()] + param( + [AllowEmptyCollection()][object[]]$InputObject = @(), + [Parameter(Mandatory)][scriptblock]$KeySelector + ) + + $entries = [System.Collections.Generic.List[object]]::new() + $index = 0 + foreach ($item in @($InputObject)) { + if ($null -eq $item) { continue } + $key = $item | ForEach-Object $KeySelector + [void]$entries.Add([PSCustomObject]@{ Key = "$key"; Index = $index; Value = $item }) + $index++ + } + $entries.Sort([System.Comparison[object]]{ + param($left, $right) + $comparison = [System.StringComparer]::Ordinal.Compare($left.Key, $right.Key) + if ($comparison -ne 0) { return $comparison } + return $left.Index.CompareTo($right.Index) + }) + return @($entries | ForEach-Object Value) +} + +function ConvertTo-IACSnapshotRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + $InputObject + ) + + process { + $requiredProperties = Get-IACAssignmentRecordPropertyNames + $missing = @($requiredProperties | Where-Object { $null -eq $InputObject.PSObject.Properties[$_] }) + if ($missing.Count -gt 0) { + throw "Snapshot input is not a canonical assignment record; missing properties: $($missing -join ', ')." + } + $recordSchemaVersion = 0 + if (-not [int]::TryParse("$($InputObject.SchemaVersion)", [ref]$recordSchemaVersion) -or $recordSchemaVersion -ne 1) { + throw "Assignment record schema version '$($InputObject.SchemaVersion)' is not supported; expected version 1." + } + if ([string]::IsNullOrWhiteSpace("$($InputObject.CategoryId)")) { + throw 'Snapshot input contains an assignment record without CategoryId.' + } + $assignmentMode = switch ("$($InputObject.AssignmentMode)".ToLowerInvariant()) { + 'include' { 'Include' } + 'exclude' { 'Exclude' } + 'none' { 'None' } + 'unknown' { 'Unknown' } + default { $null } + } + if ($null -eq $assignmentMode) { + throw "Snapshot input contains unsupported AssignmentMode '$($InputObject.AssignmentMode)'." + } + $targetType = switch ("$($InputObject.TargetType)".ToLowerInvariant()) { + 'allusers' { 'AllUsers' } + 'alldevices' { 'AllDevices' } + 'group' { 'Group' } + 'none' { 'None' } + 'unknown' { 'Unknown' } + default { $null } + } + if ($null -eq $targetType) { + throw "Snapshot input contains unsupported TargetType '$($InputObject.TargetType)'." + } + $effectiveState = if ([string]::IsNullOrWhiteSpace("$($InputObject.EffectiveState)")) { $null } + else { + switch ("$($InputObject.EffectiveState)".ToLowerInvariant()) { + 'included' { 'Included' } + 'excluded' { 'Excluded' } + 'nottargeted' { 'NotTargeted' } + 'unknown' { 'Unknown' } + default { $null } + } + } + if (-not [string]::IsNullOrWhiteSpace("$($InputObject.EffectiveState)") -and $null -eq $effectiveState) { + throw "Snapshot input contains unsupported EffectiveState '$($InputObject.EffectiveState)'." + } + + $reasonChain = foreach ($reason in @($InputObject.ReasonChain | Where-Object { $null -ne $_ })) { + $sequence = 0 + if (-not [int]::TryParse("$($reason.Sequence)", [ref]$sequence) -or $sequence -lt 0) { + throw "Snapshot input contains a reason-chain entry with invalid Sequence '$($reason.Sequence)'." + } + [PSCustomObject][ordered]@{ + Sequence = $sequence + Code = $reason.Code + FilterCode = $reason.FilterCode + Outcome = $reason.Outcome + AssignmentId = $reason.AssignmentId + AssignmentMode = $reason.AssignmentMode + TargetType = $reason.TargetType + TargetId = $reason.TargetId + MembershipSources = @(Get-IACOrdinalSortedUniqueString -InputObject @($reason.MembershipSources)) + TargetResult = $reason.TargetResult + FilterId = $reason.FilterId + FilterMode = $reason.FilterMode + FilterResult = $reason.FilterResult + Message = $reason.Message + } + } + + [PSCustomObject][ordered]@{ + SchemaVersion = 1 + TenantId = $InputObject.TenantId + TenantName = $InputObject.TenantName + SubjectType = $InputObject.SubjectType + SubjectId = $InputObject.SubjectId + SubjectName = $InputObject.SubjectName + CategoryId = $InputObject.CategoryId + Category = $InputObject.Category + PolicyId = $InputObject.PolicyId + PolicyName = $InputObject.PolicyName + Platform = $InputObject.Platform + ScopeTagIds = @(Get-IACOrdinalSortedUniqueString -InputObject @($InputObject.ScopeTagIds)) + ScopeTags = @(Get-IACOrdinalSortedUniqueString -InputObject @($InputObject.ScopeTags)) + AssignmentId = $InputObject.AssignmentId + AssignmentMode = $assignmentMode + TargetType = $targetType + TargetId = $InputObject.TargetId + TargetName = $InputObject.TargetName + Intent = $InputObject.Intent + FilterId = $InputObject.FilterId + FilterName = $InputObject.FilterName + FilterMode = $InputObject.FilterMode + FilterRule = $InputObject.FilterRule + FilterPlatform = $InputObject.FilterPlatform + EffectiveState = $effectiveState + ReasonChain = @(Get-IACOrdinalSortedObject -InputObject @($reasonChain) -KeySelector { + '{0:D10}|{1}' -f $_.Sequence, (ConvertTo-IACIdentityComponent $_.Code) + }) + AssignmentReason = $InputObject.AssignmentReason + Source = $InputObject.Source + } + } +} + +function ConvertTo-IACIdentityComponent { + param([AllowNull()]$Value) + $bytes = [System.Text.Encoding]::UTF8.GetBytes("$Value") + return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +} + +function Get-IACAssignmentIdentityKey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Record + ) + + $assignmentComponent = if (-not [string]::IsNullOrWhiteSpace("$($Record.AssignmentId)")) { + "id:$($Record.AssignmentId)" + } + elseif ($Record.AssignmentMode -eq 'None') { + 'none' + } + else { + "fallback:$($Record.AssignmentMode)|$($Record.TargetType)|$($Record.TargetId)|$($Record.Intent)" + } + return 'v1:{0}:{1}:{2}:{3}:{4}' -f @( + ConvertTo-IACIdentityComponent $Record.SubjectType + ConvertTo-IACIdentityComponent $Record.SubjectId + ConvertTo-IACIdentityComponent $Record.CategoryId + ConvertTo-IACIdentityComponent $Record.PolicyId + ConvertTo-IACIdentityComponent $assignmentComponent + ) +} + +function Get-IACInstalledModuleVersion { + [CmdletBinding()] + param() + + $loadedModule = Get-Module -Name IntuneAssignmentChecker | Select-Object -First 1 + if ($loadedModule -and $loadedModule.Version) { return $loadedModule.Version.ToString() } + + $manifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'IntuneAssignmentChecker.psd1' + return (Test-ModuleManifest -Path $manifestPath -ErrorAction Stop).Version.ToString() +} + +function New-IACAssignmentSnapshot { + [CmdletBinding()] + param( + [AllowEmptyCollection()][object[]]$Records = @(), + [Parameter(Mandatory)][datetimeoffset]$CapturedAtUtc, + [AllowEmptyCollection()][object[]]$CoverageCategories = @(), + [AllowEmptyCollection()][object[]]$CoverageErrors = @(), + [Parameter(Mandatory)][bool]$CoverageComplete, + [Parameter(Mandatory)][string]$CoverageMode + ) + + $canonicalRecords = [System.Collections.Generic.List[object]]::new() + foreach ($record in @($Records)) { + [void]$canonicalRecords.Add((ConvertTo-IACSnapshotRecord -InputObject $record)) + } + + $recordsByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($record in $canonicalRecords) { + $identityKey = Get-IACAssignmentIdentityKey -Record $record + if ($recordsByKey.ContainsKey($identityKey)) { + throw "Snapshot contains duplicate assignment identity key '$identityKey'." + } + $recordsByKey.Add($identityKey, $record) + } + $orderedRecords = @($recordsByKey.Values) + + $tenantIds = @(Get-IACOrdinalSortedUniqueString -InputObject @( + $orderedRecords.TenantId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } + )) + if ($tenantIds.Count -gt 1) { throw 'Snapshot records contain more than one tenant ID.' } + if ($script:CurrentTenantId -and $tenantIds.Count -eq 1 -and "$script:CurrentTenantId" -ne "$($tenantIds[0])") { + throw "Snapshot record tenant '$($tenantIds[0])' does not match the connected tenant '$script:CurrentTenantId'." + } + $tenantNames = @(Get-IACOrdinalSortedUniqueString -InputObject @( + $orderedRecords.TenantName | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } + )) + $resolvedTenantId = if ($script:CurrentTenantId) { "$script:CurrentTenantId" } elseif ($tenantIds.Count -eq 1) { "$($tenantIds[0])" } else { $null } + if ([string]::IsNullOrWhiteSpace($resolvedTenantId)) { + throw 'A tenant ID is required to export an assignment snapshot; connect first or supply records with TenantId.' + } + + [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentSnapshot' + SchemaVersion = 1 + CapturedAtUtc = $CapturedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ', [System.Globalization.CultureInfo]::InvariantCulture) + ModuleVersion = Get-IACInstalledModuleVersion + Tenant = [PSCustomObject][ordered]@{ + Id = $resolvedTenantId + Name = if ($script:CurrentTenantName) { "$script:CurrentTenantName" } elseif ($tenantNames.Count -eq 1) { "$($tenantNames[0])" } else { $null } + } + Coverage = [PSCustomObject][ordered]@{ + Mode = $CoverageMode + Complete = $CoverageComplete + RecordCount = $orderedRecords.Count + Categories = @(Get-IACOrdinalSortedObject -InputObject @($CoverageCategories) -KeySelector { + '{0}|{1}' -f (ConvertTo-IACIdentityComponent $_.CategoryId), (ConvertTo-IACIdentityComponent $_.DisplayName) + }) + Errors = @(Get-IACOrdinalSortedObject -InputObject @($CoverageErrors) -KeySelector { + '{0}|{1}' -f (ConvertTo-IACIdentityComponent $_.CategoryId), (ConvertTo-IACIdentityComponent $_.Message) + }) + } + Records = $orderedRecords + } +} + +function Write-IACAssignmentSnapshot { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Snapshot, + [Parameter(Mandatory)][string]$Path + ) + + $json = $Snapshot | ConvertTo-Json -Depth 20 + $json = ($json -replace "`r`n", "`n").TrimEnd("`r", "`n") + "`n" + [System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false)) +} + +function Read-IACAssignmentSnapshot { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Assignment snapshot '$Path' does not exist." + } + try { + $snapshot = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop + } + catch { + throw "Assignment snapshot '$Path' is not valid JSON: $($_.Exception.Message)" + } + if ($snapshot -is [array] -or $null -eq $snapshot) { throw "Assignment snapshot '$Path' must contain one JSON object." } + if ($snapshot.SchemaName -ne 'IntuneAssignmentChecker.AssignmentSnapshot') { + throw "Assignment snapshot '$Path' has schema '$($snapshot.SchemaName)'; expected 'IntuneAssignmentChecker.AssignmentSnapshot'." + } + $snapshotSchemaVersion = 0 + if (-not [int]::TryParse("$($snapshot.SchemaVersion)", [ref]$snapshotSchemaVersion) -or $snapshotSchemaVersion -ne 1) { + throw "Assignment snapshot '$Path' uses unsupported schema version '$($snapshot.SchemaVersion)'; expected version 1." + } + foreach ($property in @('CapturedAtUtc', 'ModuleVersion', 'Tenant', 'Coverage', 'Records')) { + if ($null -eq $snapshot.PSObject.Properties[$property]) { + throw "Assignment snapshot '$Path' is malformed: missing '$property'." + } + } + $captured = [datetimeoffset]::MinValue + if (-not [datetimeoffset]::TryParse("$($snapshot.CapturedAtUtc)", [ref]$captured)) { + throw "Assignment snapshot '$Path' has an invalid CapturedAtUtc value." + } + if ("$($snapshot.ModuleVersion)" -notmatch '^\d+(?:\.\d+){1,3}(?:[-+].+)?$') { + throw "Assignment snapshot '$Path' has an invalid ModuleVersion value." + } + if ($null -eq $snapshot.Tenant -or $null -eq $snapshot.Coverage -or $null -eq $snapshot.Records) { + throw "Assignment snapshot '$Path' is malformed: Tenant, Coverage, and Records cannot be null." + } + foreach ($property in @('Id', 'Name')) { + if ($null -eq $snapshot.Tenant.PSObject.Properties[$property]) { + throw "Assignment snapshot '$Path' is malformed: Tenant is missing '$property'." + } + } + if ([string]::IsNullOrWhiteSpace("$($snapshot.Tenant.Id)")) { + throw "Assignment snapshot '$Path' is malformed: Tenant.Id cannot be empty." + } + foreach ($property in @('Mode', 'Complete', 'RecordCount', 'Categories', 'Errors')) { + if ($null -eq $snapshot.Coverage.PSObject.Properties[$property]) { + throw "Assignment snapshot '$Path' is malformed: Coverage is missing '$property'." + } + } + if ($snapshot.Coverage.Complete -isnot [bool]) { + throw "Assignment snapshot '$Path' is malformed: Coverage.Complete must be a Boolean." + } + if ($snapshot.Coverage.Mode -cnotin @('TenantScan', 'ProvidedRecords')) { + throw "Assignment snapshot '$Path' is malformed: unsupported Coverage.Mode '$($snapshot.Coverage.Mode)'." + } + $recordCount = 0 + if (-not [int]::TryParse("$($snapshot.Coverage.RecordCount)", [ref]$recordCount) -or $recordCount -lt 0) { + throw "Assignment snapshot '$Path' is malformed: Coverage.RecordCount must be a non-negative integer." + } + if ($null -eq $snapshot.Coverage.Categories -or $null -eq $snapshot.Coverage.Errors) { + throw "Assignment snapshot '$Path' is malformed: Coverage.Categories and Coverage.Errors cannot be null." + } + foreach ($category in @($snapshot.Coverage.Categories)) { + foreach ($property in @('CategoryId', 'DisplayName', 'Status', 'RecordCount')) { + if ($null -eq $category.PSObject.Properties[$property]) { + throw "Assignment snapshot '$Path' is malformed: a coverage category is missing '$property'." + } + } + if ([string]::IsNullOrWhiteSpace("$($category.CategoryId)")) { + throw "Assignment snapshot '$Path' is malformed: a coverage category has no CategoryId." + } + if ($category.Status -cnotin @('Captured', 'Provided', 'Failed', 'Skipped', 'Unknown')) { + throw "Assignment snapshot '$Path' is malformed: unsupported coverage status '$($category.Status)'." + } + $categoryRecordCount = 0 + if (-not [int]::TryParse("$($category.RecordCount)", [ref]$categoryRecordCount) -or $categoryRecordCount -lt 0) { + throw "Assignment snapshot '$Path' is malformed: a coverage category has an invalid RecordCount." + } + } + foreach ($coverageError in @($snapshot.Coverage.Errors)) { + foreach ($property in @('CategoryId', 'Message')) { + if ($null -eq $coverageError.PSObject.Properties[$property]) { + throw "Assignment snapshot '$Path' is malformed: a coverage error is missing '$property'." + } + } + if ([string]::IsNullOrWhiteSpace("$($coverageError.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($coverageError.Message)")) { + throw "Assignment snapshot '$Path' is malformed: coverage errors require CategoryId and Message." + } + } + + $validatedRecords = [System.Collections.Generic.List[object]]::new() + foreach ($record in @($snapshot.Records)) { + try { [void]$validatedRecords.Add((ConvertTo-IACSnapshotRecord -InputObject $record)) } + catch { throw "Assignment snapshot '$Path' contains an invalid record: $($_.Exception.Message)" } + } + if ($recordCount -ne $validatedRecords.Count) { + throw "Assignment snapshot '$Path' record count does not match Coverage.RecordCount." + } + $coverageByCategory = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($category in @($snapshot.Coverage.Categories)) { + if ($coverageByCategory.ContainsKey("$($category.CategoryId)")) { + throw "Assignment snapshot '$Path' contains duplicate coverage category '$($category.CategoryId)'." + } + $coverageByCategory.Add("$($category.CategoryId)", $category) + } + foreach ($record in $validatedRecords) { + if (-not $coverageByCategory.ContainsKey("$($record.CategoryId)")) { + throw "Assignment snapshot '$Path' contains record category '$($record.CategoryId)' outside declared coverage." + } + } + foreach ($categoryId in $coverageByCategory.Keys) { + $actualCount = @($validatedRecords | Where-Object CategoryId -CEQ $categoryId).Count + if ([int]$coverageByCategory[$categoryId].RecordCount -ne $actualCount) { + throw "Assignment snapshot '$Path' has an incorrect RecordCount for coverage category '$categoryId'." + } + } + foreach ($coverageError in @($snapshot.Coverage.Errors)) { + if (-not $coverageByCategory.ContainsKey("$($coverageError.CategoryId)")) { + throw "Assignment snapshot '$Path' contains a coverage error outside declared categories." + } + } + if ([bool]$snapshot.Coverage.Complete -and + (@($snapshot.Coverage.Errors).Count -gt 0 -or @($snapshot.Coverage.Categories | Where-Object Status -in @('Failed', 'Skipped', 'Unknown')).Count -gt 0)) { + throw "Assignment snapshot '$Path' is malformed: complete coverage cannot contain failed, skipped, or unknown categories." + } + $recordsByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($record in $validatedRecords) { + $identityKey = Get-IACAssignmentIdentityKey -Record $record + if ($recordsByKey.ContainsKey($identityKey)) { + throw "Assignment snapshot '$Path' contains duplicate identity key '$identityKey'." + } + $recordsByKey.Add($identityKey, $record) + } + + $snapshot.Records = @($recordsByKey.Values) + return $snapshot +} diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index ba765a7..2f7c00d 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -49,6 +49,7 @@ function Invoke-IntuneCategoryScan { } $scanErrors = [System.Collections.Generic.List[object]]::new() + $skippedCategories = [System.Collections.Generic.List[object]]::new() $records = [System.Collections.Generic.List[object]]::new() function Get-CachedEntitySet { @@ -204,6 +205,11 @@ function Invoke-IntuneCategoryScan { catch { if ($category.OptionalFeature) { # Optional features (e.g. Windows 365) fail quietly when the tenant is not licensed + $skippedCategories.Add([PSCustomObject]@{ + CategoryId = $category.Id + DisplayName = $category.DisplayName + Message = $_.Exception.Message + }) Write-Verbose "Skipping optional category '$($category.DisplayName)': $($_.Exception.Message)" continue } @@ -223,6 +229,7 @@ function Invoke-IntuneCategoryScan { Buckets = $buckets Records = $records Errors = $scanErrors + Skipped = $skippedCategories CategoryCount = $totalCategories } } diff --git a/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 new file mode 100644 index 0000000..6e52981 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 @@ -0,0 +1,168 @@ +function Compare-IntuneAssignmentSnapshot { + <# + .SYNOPSIS + Compares two Intune assignment snapshot files. + + .DESCRIPTION + Reports Added, Removed, and Changed canonical assignment records using a + schema-versioned stable identity key. By default, comparison rejects + failed or unknown coverage, cross-tenant, or category-coverage-mismatched + snapshots. Categories marked Skipped are excluded from both sides with a warning. + + .PARAMETER ReferencePath + Older or baseline snapshot. + + .PARAMETER DifferencePath + Newer snapshot. + + .PARAMETER AllowIncompleteCoverage + Allows comparison when either snapshot contains failed or unknown coverage. + + .PARAMETER AllowCoverageMismatch + Allows comparison when the snapshots cover different category ID sets. + + .EXAMPLE + Compare-IntuneAssignmentSnapshot -ReferencePath './before.json' -DifferencePath './after.json' + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentSnapshotDifference')] + param( + [Parameter(Mandatory)] + [string]$ReferencePath, + + [Parameter(Mandatory)] + [string]$DifferencePath, + + [Parameter()] + [switch]$AllowIncompleteCoverage, + + [Parameter()] + [switch]$AllowCoverageMismatch + ) + + $reference = Read-IACAssignmentSnapshot -Path $ReferencePath + $difference = Read-IACAssignmentSnapshot -Path $DifferencePath + + if ("$($reference.Tenant.Id)" -cne "$($difference.Tenant.Id)") { + throw "Snapshots belong to different tenants ('$($reference.Tenant.Id)' and '$($difference.Tenant.Id)')." + } + $hasBlockingIncompleteCoverage = $false + foreach ($snapshot in @($reference, $difference)) { + if ([bool]$snapshot.Coverage.Complete) { continue } + + $snapshotSkippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $hasBlockingStatus = $false + foreach ($category in @($snapshot.Coverage.Categories)) { + if ($category.Status -ceq 'Skipped') { + [void]$snapshotSkippedCategories.Add("$($category.CategoryId)") + } + elseif ($category.Status -in @('Failed', 'Unknown')) { + $hasBlockingStatus = $true + } + } + $hasNonSkippedError = $false + foreach ($coverageError in @($snapshot.Coverage.Errors)) { + if (-not $snapshotSkippedCategories.Contains("$($coverageError.CategoryId)")) { + $hasNonSkippedError = $true + break + } + } + if ($hasBlockingStatus -or $hasNonSkippedError -or $snapshotSkippedCategories.Count -eq 0) { + $hasBlockingIncompleteCoverage = $true + break + } + } + if (-not $AllowIncompleteCoverage -and $hasBlockingIncompleteCoverage) { + throw 'One or both snapshots have incomplete category coverage; use -AllowIncompleteCoverage to compare them explicitly.' + } + $referenceCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($categoryId in @($reference.Coverage.Categories.CategoryId)) { [void]$referenceCategories.Add("$categoryId") } + $differenceCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($categoryId in @($difference.Coverage.Categories.CategoryId)) { [void]$differenceCategories.Add("$categoryId") } + if (-not $AllowCoverageMismatch -and -not $referenceCategories.SetEquals($differenceCategories)) { + throw 'Snapshots cover different category sets; use -AllowCoverageMismatch to compare them explicitly.' + } + + $skippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($category in @($reference.Coverage.Categories) + @($difference.Coverage.Categories)) { + if ($category.Status -ceq 'Skipped') { + [void]$skippedCategories.Add("$($category.CategoryId)") + } + } + if ($skippedCategories.Count -gt 0) { + $orderedSkippedCategories = [System.Collections.Generic.List[string]]::new($skippedCategories) + $orderedSkippedCategories.Sort([System.StringComparer]::Ordinal) + Write-Warning "Skipped categories were not compared: $($orderedSkippedCategories -join ', ')." + } + + $referenceByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($record in @($reference.Records)) { + if ($skippedCategories.Contains("$($record.CategoryId)")) { continue } + $referenceByKey.Add((Get-IACAssignmentIdentityKey -Record $record), $record) + } + $differenceByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($record in @($difference.Records)) { + if ($skippedCategories.Contains("$($record.CategoryId)")) { continue } + $differenceByKey.Add((Get-IACAssignmentIdentityKey -Record $record), $record) + } + + $changes = [System.Collections.Generic.List[object]]::new() + $propertyNames = Get-IACAssignmentRecordPropertyNames + foreach ($key in $differenceByKey.Keys) { + if (-not $referenceByKey.ContainsKey($key)) { + $after = $differenceByKey[$key] + [void]$changes.Add([PSCustomObject][ordered]@{ + ChangeType = 'Added' + IdentityKey = $key + CategoryId = $after.CategoryId + PolicyId = $after.PolicyId + AssignmentId = $after.AssignmentId + ChangedFields = @() + Before = $null + After = $after + }) + continue + } + + $before = $referenceByKey[$key] + $after = $differenceByKey[$key] + $changedFields = foreach ($propertyName in $propertyNames) { + $beforeJson = ConvertTo-Json -InputObject $before.$propertyName -Depth 12 -Compress + $afterJson = ConvertTo-Json -InputObject $after.$propertyName -Depth 12 -Compress + if (-not [string]::Equals($beforeJson, $afterJson, [System.StringComparison]::Ordinal)) { $propertyName } + } + if (@($changedFields).Count -gt 0) { + [void]$changes.Add([PSCustomObject][ordered]@{ + ChangeType = 'Changed' + IdentityKey = $key + CategoryId = $after.CategoryId + PolicyId = $after.PolicyId + AssignmentId = $after.AssignmentId + ChangedFields = @($changedFields) + Before = $before + After = $after + }) + } + } + foreach ($key in $referenceByKey.Keys) { + if ($differenceByKey.ContainsKey($key)) { continue } + $before = $referenceByKey[$key] + [void]$changes.Add([PSCustomObject][ordered]@{ + ChangeType = 'Removed' + IdentityKey = $key + CategoryId = $before.CategoryId + PolicyId = $before.PolicyId + AssignmentId = $before.AssignmentId + ChangedFields = @() + Before = $before + After = $null + }) + } + + foreach ($changeType in @('Added', 'Removed', 'Changed')) { + foreach ($change in @($changes | Where-Object ChangeType -CEQ $changeType)) { + $change.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentSnapshotDifference') + Write-Output $change + } + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Export-IntuneAssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Public/Export-IntuneAssignmentSnapshot.ps1 new file mode 100644 index 0000000..a5262e3 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Export-IntuneAssignmentSnapshot.ps1 @@ -0,0 +1,235 @@ +function Export-IntuneAssignmentSnapshot { + <# + .SYNOPSIS + Captures Intune assignment state in a deterministic, schema-versioned JSON file. + + .DESCRIPTION + With no pipeline input, scans the connected tenant through the shared category + engine. Canonical assignment records are sorted by stable identity and projected + through a fixed allowlist so credentials, tokens, and arbitrary extra properties + cannot enter the snapshot. + + .PARAMETER Path + Destination .json file. + + .PARAMETER InputObject + Optional canonical assignment records to snapshot instead of scanning the tenant. + + .PARAMETER CoverageCategory + Optional category IDs describing the coverage of supplied InputObject records. + + .PARAMETER CoverageComplete + Declares supplied InputObject coverage complete. Supplied records default to + incomplete because the cmdlet cannot infer whether an upstream scan failed. + + .PARAMETER CoverageError + Optional objects with CategoryId and Message describing upstream scan failures. + + .PARAMETER CapturedAtUtc + Capture timestamp. Defaults to the current UTC time; it can be fixed for + reproducible builds and tests. + + .PARAMETER Force + Overwrites an existing snapshot file. + + .PARAMETER PassThru + Returns the in-memory snapshot document after writing it. + + .EXAMPLE + Export-IntuneAssignmentSnapshot -Path './snapshots/intune.json' + + .EXAMPLE + Get-IntuneAllPolicies -PassThru | Export-IntuneAssignmentSnapshot -Path './snapshot.json' -CoverageComplete + #> + [CmdletBinding(DefaultParameterSetName = 'Tenant')] + [OutputType('IntuneAssignmentChecker.AssignmentSnapshot')] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Records')] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter(ParameterSetName = 'Records')] + [string[]]$CoverageCategory = @(), + + [Parameter(ParameterSetName = 'Records')] + [switch]$CoverageComplete, + + [Parameter(ParameterSetName = 'Records')] + [object[]]$CoverageError = @(), + + [Parameter()] + [datetimeoffset]$CapturedAtUtc = [datetimeoffset]::UtcNow, + + [Parameter()] + [switch]$Force, + + [Parameter()] + [switch]$PassThru + ) + + begin { + $inputRecords = [System.Collections.Generic.List[object]]::new() + } + + process { + if ($PSCmdlet.ParameterSetName -eq 'Records') { + foreach ($record in @($InputObject)) { + if ($null -ne $record) { [void]$inputRecords.Add($record) } + } + } + } + + end { + if ([System.IO.Path]::GetExtension($Path) -ine '.json') { + throw 'Path must be a .json file.' + } + $resolvedPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) + if ((Test-Path -LiteralPath $resolvedPath) -and -not $Force) { + throw "Assignment snapshot '$resolvedPath' already exists; use -Force to overwrite it." + } + $parentPath = Split-Path -Parent $resolvedPath + if ($parentPath -and -not (Test-Path -LiteralPath $parentPath)) { + New-Item -ItemType Directory -Path $parentPath -Force | Out-Null + } + + $coverageErrors = @() + if ($PSCmdlet.ParameterSetName -eq 'Tenant') { + if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) { + throw 'Connect first with Connect-IntuneAssignmentChecker.' + } + if ($null -eq $script:AssignmentFilterLookup) { + $script:AssignmentFilterLookup = Get-AssignmentFilterLookup + } + $categories = @(Get-IntuneCategoryDefinition -Audience Effective) + $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity {} -EntityCache @{} ` + -ShowProgress -ProgressVerb 'Capturing' -BuildRecords + $records = @($scan.Records | Where-Object { $null -ne $_ }) + $coverageErrors = @($scan.Errors | Where-Object { $null -ne $_ } | ForEach-Object { + if ([string]::IsNullOrWhiteSpace("$($_.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($_.Message)")) { + throw 'The category scanner returned an error without CategoryId or Message.' + } + [PSCustomObject][ordered]@{ + CategoryId = $_.CategoryId + Message = $_.Message + } + }) + $coverageSkipped = @($scan.Skipped | Where-Object { $null -ne $_ } | ForEach-Object { + if ([string]::IsNullOrWhiteSpace("$($_.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($_.Message)")) { + throw 'The category scanner returned a skipped category without CategoryId or Message.' + } + [PSCustomObject][ordered]@{ + CategoryId = $_.CategoryId + Message = $_.Message + } + }) + $declaredCategoryIds = foreach ($category in $categories) { + if ($null -eq $category -or [string]::IsNullOrWhiteSpace("$($category.Id)")) { + throw 'The category scanner returned a category definition without Id.' + } + "$($category.Id)" + } + $declaredCategoryIds = @($declaredCategoryIds) + $uniqueDeclaredCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject $declaredCategoryIds) + if ($declaredCategoryIds.Count -ne $uniqueDeclaredCategoryIds.Count) { + throw 'The category scanner returned duplicate category definitions.' + } + $recordCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @( + $records.CategoryId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } + )) + $missingCoverage = @($recordCategoryIds | Where-Object { $_ -cnotin $uniqueDeclaredCategoryIds }) + if ($missingCoverage.Count -gt 0) { + throw "The category scanner returned records outside declared coverage: $($missingCoverage -join ', ')." + } + $errorsOutsideCoverage = @($coverageErrors | Where-Object { $_.CategoryId -cnotin $uniqueDeclaredCategoryIds } | + ForEach-Object { $_.CategoryId }) + if ($errorsOutsideCoverage.Count -gt 0) { + throw "The category scanner returned errors outside declared coverage: $($errorsOutsideCoverage -join ', ')." + } + $skipsOutsideCoverage = @($coverageSkipped | Where-Object { $_.CategoryId -cnotin $uniqueDeclaredCategoryIds } | + ForEach-Object { $_.CategoryId }) + if ($skipsOutsideCoverage.Count -gt 0) { + throw "The category scanner returned skipped categories outside declared coverage: $($skipsOutsideCoverage -join ', ')." + } + $coverageCategories = foreach ($category in $categories) { + $categoryError = $coverageErrors | Where-Object CategoryId -CEQ $category.Id | Select-Object -First 1 + $categorySkipped = $coverageSkipped | Where-Object CategoryId -CEQ $category.Id | Select-Object -First 1 + [PSCustomObject][ordered]@{ + CategoryId = $category.Id + DisplayName = $category.DisplayName + Status = if ($categoryError) { 'Failed' } elseif ($categorySkipped) { 'Skipped' } else { 'Captured' } + RecordCount = @($records | Where-Object CategoryId -CEQ $category.Id).Count + } + } + $coverageErrors = @($coverageErrors) + @($coverageSkipped) + $coverageMode = 'TenantScan' + $resolvedCoverageComplete = @($coverageErrors).Count -eq 0 + } + else { + $records = @($inputRecords) + $recordCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @( + $records.CategoryId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } + )) + $declaredCategoryIds = if ($CoverageCategory.Count -gt 0) { + @(Get-IACOrdinalSortedUniqueString -InputObject @( + $CoverageCategory | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + )) + } + else { + $recordCategoryIds + } + $coverageErrors = foreach ($coverageErrorItem in @($CoverageError)) { + $hasCategoryId = if ($coverageErrorItem -is [System.Collections.IDictionary]) { + $coverageErrorItem.Contains('CategoryId') + } + else { $null -ne $coverageErrorItem.PSObject.Properties['CategoryId'] } + $hasMessage = if ($coverageErrorItem -is [System.Collections.IDictionary]) { + $coverageErrorItem.Contains('Message') + } + else { $null -ne $coverageErrorItem.PSObject.Properties['Message'] } + if (-not $hasCategoryId -or -not $hasMessage) { + throw 'Every CoverageError must contain CategoryId and Message.' + } + $errorCategoryId = "$($coverageErrorItem.CategoryId)" + $errorMessage = "$($coverageErrorItem.Message)" + if ([string]::IsNullOrWhiteSpace($errorCategoryId) -or [string]::IsNullOrWhiteSpace($errorMessage)) { + throw 'Every CoverageError requires non-empty CategoryId and Message.' + } + [PSCustomObject][ordered]@{ + CategoryId = $errorCategoryId + Message = $errorMessage + } + } + $categoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @( + $declaredCategoryIds + $coverageErrors.CategoryId + )) + $missingCoverage = @($recordCategoryIds | Where-Object { $_ -cnotin $categoryIds }) + if ($missingCoverage.Count -gt 0) { + throw "CoverageCategory does not include record categories: $($missingCoverage -join ', ')." + } + $coverageCategories = foreach ($categoryId in $categoryIds) { + $categoryError = $coverageErrors | Where-Object CategoryId -CEQ $categoryId | Select-Object -First 1 + [PSCustomObject][ordered]@{ + CategoryId = "$categoryId" + DisplayName = $null + Status = if ($categoryError) { 'Failed' } elseif ($CoverageComplete) { 'Provided' } else { 'Unknown' } + RecordCount = @($records | Where-Object CategoryId -CEQ $categoryId).Count + } + } + $coverageMode = 'ProvidedRecords' + $resolvedCoverageComplete = [bool]$CoverageComplete -and @($coverageErrors).Count -eq 0 + } + + $snapshot = New-IACAssignmentSnapshot -Records $records -CapturedAtUtc $CapturedAtUtc ` + -CoverageCategories @($coverageCategories) -CoverageErrors @($coverageErrors) ` + -CoverageComplete $resolvedCoverageComplete -CoverageMode $coverageMode + $snapshot.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentSnapshot') + Write-IACAssignmentSnapshot -Snapshot $snapshot -Path $resolvedPath + Write-Verbose "Assignment snapshot written to '$resolvedPath'." + + if ($PassThru) { return $snapshot } + } +} diff --git a/README.md b/README.md index b3b6100..f2d39ea 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ IntuneAssignmentChecker - ๐ŸŽฏ See Intune assignment filters (name and Include/Exclude type) inline on every assignment, in the console, CSV exports, and HTML reports - ๐Ÿ›ก๏ธ Safely test managed-device assignment-filter rules locally with `Test-IntuneAssignmentFilter` and tri-state `Match`, `NotMatch`, or `Unknown` results; tenant rule text is never executed - ๐Ÿงญ Explain effective targeting for a user, managed device, or both with exclusion precedence, transitive group membership, assignment filters, and machine-readable reason chains +- ๐Ÿ“ธ Capture deterministic assignment snapshots and compare Added, Removed, and Changed records between runs - ๐Ÿ” Support for certificate-based and client secret authentication - ๐Ÿ”„ Version check on connect with an update notice when a newer PSGallery release is available - ๐Ÿ“Š Detailed reporting of Configuration Profiles, Compliance Policies, and Applications @@ -404,6 +405,14 @@ Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' -DeviceName $effective = Get-IntuneEffectiveAssignment -UserPrincipalName 'user@contoso.com' ` -DeviceName 'Laptop123' -PassThru -ExportPath 'C:\Temp\EffectiveAssignments.csv' $effective | Where-Object EffectiveState -in 'Excluded', 'Unknown' + +# Capture the tenant assignment baseline as deterministic, schema-versioned JSON +Export-IntuneAssignmentSnapshot -Path 'C:\IntuneSnapshots\assignments.json' -Force + +# Compare a checked-in baseline with a newer scheduled capture +Compare-IntuneAssignmentSnapshot ` + -ReferencePath 'C:\IntuneSnapshots\baseline.json' ` + -DifferencePath 'C:\IntuneSnapshots\latest.json' ``` `Get-IntuneUserAssignment`, `Get-IntuneGroupAssignment`, @@ -449,6 +458,36 @@ automation from mistaking an unreadable category for a category with no matching assignments. CSV rows also expose the final `DecisionCode`; inspect the full JSON `ReasonChain` for every target, filter, and precedence decision. +Assignment snapshots use the `IntuneAssignmentChecker.AssignmentSnapshot` schema +version `1`. They contain the UTC capture time, module version, tenant identity, +per-category coverage and errors, and canonical assignment records sorted by a +stable identity key. Only the documented canonical fields are serialized; arbitrary +properties such as access tokens or client secrets are discarded. Apps are covered +when they have at least one tenant assignment, matching the shared assignment scan. +With a fixed `-CapturedAtUtc`, equivalent inputs produce byte-identical UTF-8 JSON +on every platform. The normal current-time value intentionally changes per capture. +Difference rows expose an opaque, versioned `IdentityKey`; compare it as a whole but +do not parse it. When Graph omits an assignment ID, the fallback identity includes +the assignment intent, so an intent change appears as an Added/Removed pair rather +than one Changed row. + +For scheduled auditing, export to a dated file, compare it with the last accepted +baseline, and archive or commit the JSON to source control. `Compare-IntuneAssignmentSnapshot` +rejects malformed schemas, different tenants, failed or unknown scans, and mismatched +category coverage by default, so a permission or service failure cannot masquerade +as assignment removal. Optional workloads that cannot be fetched are marked +`Skipped`; comparison warns and excludes those categories from both snapshots, so +an unavailable optional workload produces neither false removals nor false additions. +Failed and unknown categories remain blocked. The explicit `-AllowIncompleteCoverage` and +`-AllowCoverageMismatch` switches are intended for investigated exceptions, not +routine automation. Snapshot files contain tenant configuration and names, so use +the same repository access controls as other Intune configuration exports. +Snapshots built from `-InputObject` default to incomplete because the exporter +cannot see an upstream command's error stream. Supply the full `-CoverageCategory` +set and `-CoverageComplete` only when the producer is known to have completed; +otherwise pass structured `-CoverageError` entries and keep the snapshot blocked +from routine comparison. + `Get-IntuneGroupAssignment` CSV/Excel exports include `GroupId`, `GroupName`, `GroupType`, `MembershipType`, and `GroupMail` on every group and policy/app row. This keeps multi-group exports attributable and lets workbooks distinguish @@ -481,6 +520,8 @@ Available cmdlets: | `Get-IntuneGroupAssignment` | Check assignments for specific groups | | `Get-IntuneDeviceAssignment` | Check assignments for specific devices | | `Get-IntuneEffectiveAssignment` | Explain effective targeting for a user, managed device, or both | +| `Export-IntuneAssignmentSnapshot` | Capture deterministic, schema-versioned assignment JSON | +| `Compare-IntuneAssignmentSnapshot` | Report Added, Removed, and Changed records between snapshots | | `Get-IntuneAllPolicies` | Show all policies and their assignments | | `Get-IntuneAllUsersAssignment` | Show all 'All Users' assignments | | `Get-IntuneAllDevicesAssignment` | Show all 'All Devices' assignments | diff --git a/Tests/Unit/AssignmentSnapshot.Tests.ps1 b/Tests/Unit/AssignmentSnapshot.Tests.ps1 new file mode 100644 index 0000000..33e3d24 --- /dev/null +++ b/Tests/Unit/AssignmentSnapshot.Tests.ps1 @@ -0,0 +1,488 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $modulePrivate = Join-Path $moduleRoot Private + + . (Join-Path $modulePrivate 'New-IACAssignmentRecord.ps1') + . (Join-Path $modulePrivate 'AssignmentSnapshot.ps1') + . (Join-Path $moduleRoot 'Public/Export-IntuneAssignmentSnapshot.ps1') + . (Join-Path $moduleRoot 'Public/Compare-IntuneAssignmentSnapshot.ps1') + + function Get-AssignmentFilterLookup { @{} } + function Get-IntuneCategoryDefinition { param([string]$Audience) @() } + function Invoke-IntuneCategoryScan { + param([object[]]$Categories, [scriptblock]$ProcessEntity, [hashtable]$EntityCache, [switch]$ShowProgress, [string]$ProgressVerb, [switch]$BuildRecords) + [PSCustomObject]@{ Records = @(); Errors = @(); Skipped = @() } + } + + function New-SnapshotTestRecord { + param( + [string]$PolicyId = 'policy-1', + [string]$PolicyName = 'Policy One', + [string]$AssignmentId = 'assignment-1', + [string]$Intent = 'required', + [string]$TargetId = 'group-1', + [string]$CategoryId = 'DeviceConfigurations' + ) + New-IACAssignmentRecord -CategoryId $CategoryId -Category 'Device Configuration' ` + -PolicyId $PolicyId -PolicyName $PolicyName -Platform Windows ` + -AssignmentId $AssignmentId -AssignmentMode Include -TargetType Group ` + -TargetId $TargetId -TargetName 'Group One' -Intent $Intent ` + -AssignmentReason 'Group Assignment' -Source MicrosoftGraph + } + + $script:GraphEndpoint = 'https://graph.test' + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + $script:AssignmentFilterLookup = @{} + $script:fixedCapture = [datetimeoffset]'2026-08-01T10:00:00Z' +} + +Describe 'Export-IntuneAssignmentSnapshot' { + BeforeEach { + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + $script:GraphEndpoint = 'https://graph.test' + $script:AssignmentFilterLookup = @{} + Mock Get-IntuneCategoryDefinition { @() } + Mock Invoke-IntuneCategoryScan { [PSCustomObject]@{ Records = @(); Errors = @(); Skipped = @() } } + } + + It 'round-trips schema, tenant, version, coverage, and canonical records' { + $path = Join-Path $TestDrive 'snapshot.json' + $record = New-SnapshotTestRecord + + $snapshot = $record | Export-IntuneAssignmentSnapshot -Path $path ` + -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture -PassThru + $loaded = Read-IACAssignmentSnapshot -Path $path + + $snapshot.PSObject.TypeNames | Should -Contain IntuneAssignmentChecker.AssignmentSnapshot + $loaded.SchemaName | Should -BeExactly IntuneAssignmentChecker.AssignmentSnapshot + $loaded.SchemaVersion | Should -Be 1 + $loaded.CapturedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ') | + Should -BeExactly '2026-08-01T10:00:00.0000000Z' + $loaded.ModuleVersion | Should -BeExactly '4.4.0' + $loaded.Tenant.Id | Should -BeExactly tenant-1 + $loaded.Coverage.Complete | Should -BeTrue + $loaded.Coverage.RecordCount | Should -Be 1 + $loaded.Coverage.Categories[0].CategoryId | Should -BeExactly DeviceConfigurations + $loaded.Records[0].PolicyId | Should -BeExactly policy-1 + $loaded.Records[0].PSObject.Properties.Name | Should -Be (Get-IACAssignmentRecordPropertyNames) + @($loaded.Records[0].ReasonChain).Count | Should -Be 0 + @($loaded.Records[0].ScopeTagIds).Count | Should -Be 0 + @($loaded.Records[0].ScopeTags).Count | Should -Be 0 + } + + It 'writes byte-identical JSON for the same records and capture metadata regardless of input order' { + $firstPath = Join-Path $TestDrive 'first.json' + $secondPath = Join-Path $TestDrive 'second.json' + $records = @( + New-SnapshotTestRecord -PolicyId policy-b -PolicyName 'Policy B' -AssignmentId assignment-b + New-SnapshotTestRecord -PolicyId policy-a -PolicyName 'Policy A' -AssignmentId assignment-a + ) + + $savedCulture = [System.Globalization.CultureInfo]::CurrentCulture + $savedUiCulture = [System.Globalization.CultureInfo]::CurrentUICulture + try { + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]'tr-TR' + [System.Globalization.CultureInfo]::CurrentUICulture = [System.Globalization.CultureInfo]'tr-TR' + $records | Export-IntuneAssignmentSnapshot -Path $firstPath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]'en-US' + [System.Globalization.CultureInfo]::CurrentUICulture = [System.Globalization.CultureInfo]'en-US' + @($records[1], $records[0]) | Export-IntuneAssignmentSnapshot -Path $secondPath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + } + finally { + [System.Globalization.CultureInfo]::CurrentCulture = $savedCulture + [System.Globalization.CultureInfo]::CurrentUICulture = $savedUiCulture + } + + [System.IO.File]::ReadAllBytes($firstPath) | Should -Be ([System.IO.File]::ReadAllBytes($secondPath)) + } + + It 'allowlists the canonical schema and excludes arbitrary secret or token fields' { + $path = Join-Path $TestDrive 'safe.json' + $record = New-SnapshotTestRecord + $record | Add-Member -NotePropertyName AccessToken -NotePropertyValue 'super-secret-token' + $record | Add-Member -NotePropertyName ClientSecret -NotePropertyValue 'super-secret-client-value' + + $record | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete -CapturedAtUtc $script:fixedCapture + $raw = Get-Content -LiteralPath $path -Raw + + $raw | Should -Not -Match 'super-secret' + $raw | Should -Not -Match 'AccessToken|ClientSecret' + } + + It 'preserves case-distinct array values and sorts arrays and reason chains ordinally' { + $path = Join-Path $TestDrive 'arrays.json' + $record = New-SnapshotTestRecord + $record.ScopeTagIds = @('b', 'A', 'a') + $record.ScopeTags = @('Zulu', 'istanbul', 'Istanbul') + $record.ReasonChain = @( + [PSCustomObject]@{ Sequence = 2; Code = 'Second'; MembershipSources = @('device', 'Device') } + [PSCustomObject]@{ Sequence = 1; Code = 'First' } + ) + + $record | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete -CapturedAtUtc $script:fixedCapture + $loaded = Read-IACAssignmentSnapshot $path + + $loaded.Records[0].ScopeTagIds | Should -Be @('A', 'a', 'b') + $loaded.Records[0].ScopeTags | Should -Be @('Istanbul', 'Zulu', 'istanbul') + $loaded.Records[0].ReasonChain.Code | Should -Be @('First', 'Second') + $loaded.Records[0].ReasonChain[0].MembershipSources | Should -BeNullOrEmpty + (Get-Content -LiteralPath $path -Raw) | Should -Match '"MembershipSources":\s*\[\]' + $loaded.Records[0].ReasonChain[1].MembershipSources | Should -Be @('Device', 'device') + } + + It 'keeps subject-scoped records distinct in the stable identity' { + $path = Join-Path $TestDrive 'subjects.json' + $userOne = New-SnapshotTestRecord + $userOne.SubjectType = 'User'; $userOne.SubjectId = 'user-1'; $userOne.SubjectName = 'One' + $userTwo = New-SnapshotTestRecord + $userTwo.SubjectType = 'User'; $userTwo.SubjectId = 'user-2'; $userTwo.SubjectName = 'Two' + + @($userOne, $userTwo) | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete -CapturedAtUtc $script:fixedCapture + $loaded = Read-IACAssignmentSnapshot $path + + @($loaded.Records).Count | Should -Be 2 + (Get-IACAssignmentIdentityKey $loaded.Records[0]) | Should -Not -BeExactly (Get-IACAssignmentIdentityKey $loaded.Records[1]) + } + + It 'resolves relative paths against the PowerShell location and supports Force overwrite' { + Push-Location $TestDrive + try { + Export-IntuneAssignmentSnapshot -Path './relative/snapshot.json' -InputObject @() ` + -CoverageComplete -CapturedAtUtc $script:fixedCapture + Test-Path './relative/snapshot.json' | Should -BeTrue + @(Compare-IntuneAssignmentSnapshot -ReferencePath './relative/snapshot.json' ` + -DifferencePath './relative/snapshot.json').Count | Should -Be 0 + + Export-IntuneAssignmentSnapshot -Path './relative/snapshot.json' -InputObject @() ` + -CoverageComplete -CapturedAtUtc ([datetimeoffset]'2026-08-02T10:00:00Z') -Force + (Read-IACAssignmentSnapshot './relative/snapshot.json').CapturedAtUtc.ToUniversalTime().Day | Should -Be 2 + } + finally { Pop-Location } + } + + It 'rejects declared coverage that omits a supplied record category' { + $path = Join-Path $TestDrive 'bad-coverage.json' + $records = @( + New-SnapshotTestRecord -CategoryId DeviceConfigurations + New-SnapshotTestRecord -PolicyId compliance-1 -AssignmentId compliance-a -CategoryId CompliancePolicies + ) + + { $records | Export-IntuneAssignmentSnapshot -Path $path ` + -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture } | + Should -Throw '*does not include record categories*CompliancePolicies*' + } + + It 'defaults supplied records to incomplete coverage and accepts explicit coverage errors' { + $path = Join-Path $TestDrive 'provided-incomplete.json' + $record = New-SnapshotTestRecord + $coverageError = [PSCustomObject]@{ CategoryId = 'CompliancePolicies'; Message = 'HTTP 403' } + + $snapshot = $record | Export-IntuneAssignmentSnapshot -Path $path ` + -CoverageCategory DeviceConfigurations -CoverageError $coverageError ` + -CapturedAtUtc $script:fixedCapture -PassThru + + $snapshot.Coverage.Complete | Should -BeFalse + ($snapshot.Coverage.Categories | Where-Object CategoryId -eq CompliancePolicies).Status | Should -BeExactly Failed + (Read-IACAssignmentSnapshot -Path $path).Coverage.Categories.CategoryId | Should -Contain CompliancePolicies + { Compare-IntuneAssignmentSnapshot -ReferencePath $path -DifferencePath $path } | + Should -Throw '*incomplete category coverage*' + @(Compare-IntuneAssignmentSnapshot -ReferencePath $path -DifferencePath $path -AllowIncompleteCoverage).Count | Should -Be 0 + } + + It 'rejects empty supplied coverage errors before writing' { + $path = Join-Path $TestDrive 'empty-error.json' + $record = New-SnapshotTestRecord + $emptyError = [PSCustomObject]@{ CategoryId = 'CompliancePolicies'; Message = '' } + + { $record | Export-IntuneAssignmentSnapshot -Path $path -CoverageError $emptyError ` + -CapturedAtUtc $script:fixedCapture } | Should -Throw '*requires non-empty CategoryId and Message*' + Test-Path $path | Should -BeFalse + } + + It 'round-trips legal four-component PowerShell module versions' { + $path = Join-Path $TestDrive 'four-part-version.json' + Mock Get-IACInstalledModuleVersion { '4.4.0.1' } + + Export-IntuneAssignmentSnapshot -Path $path -InputObject @() -CoverageComplete -CapturedAtUtc $script:fixedCapture + + (Read-IACAssignmentSnapshot -Path $path).ModuleVersion | Should -BeExactly '4.4.0.1' + } + + It 'requires a tenant identity and covers no-id fallback identities' { + $path = Join-Path $TestDrive 'fallback.json' + $fallbackOne = New-SnapshotTestRecord -PolicyId policy-fallback -AssignmentId '' -TargetId group-1 + $fallbackTwo = New-SnapshotTestRecord -PolicyId policy-fallback -AssignmentId '' -TargetId group-2 + $fallbackOne.AssignmentMode = 'include' + $fallbackOne.TargetType = 'group' + $fallbackOne.EffectiveState = 'included' + (Get-IACAssignmentIdentityKey $fallbackOne) | Should -Not -BeExactly (Get-IACAssignmentIdentityKey $fallbackTwo) + + @($fallbackOne, $fallbackTwo) | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete -CapturedAtUtc $script:fixedCapture + $loaded = Read-IACAssignmentSnapshot $path + @($loaded.Records).Count | Should -Be 2 + ($loaded.Records | Where-Object TargetId -eq group-1).AssignmentMode | Should -BeExactly Include + ($loaded.Records | Where-Object TargetId -eq group-1).TargetType | Should -BeExactly Group + ($loaded.Records | Where-Object TargetId -eq group-1).EffectiveState | Should -BeExactly Included + + $script:CurrentTenantId = $null + $script:CurrentTenantName = $null + $tenantless = New-SnapshotTestRecord + { $tenantless | Export-IntuneAssignmentSnapshot -Path (Join-Path $TestDrive 'tenantless.json') ` + -CoverageComplete -CapturedAtUtc $script:fixedCapture } | Should -Throw '*tenant ID is required*' + } + + It 'captures the shared tenant scan and records per-category coverage failures' { + $path = Join-Path $TestDrive 'tenant.json' + $record = New-SnapshotTestRecord + Mock Get-IntuneCategoryDefinition { + @( + [PSCustomObject]@{ Id = 'DeviceConfigurations'; DisplayName = 'Device Configurations' } + [PSCustomObject]@{ Id = 'CompliancePolicies'; DisplayName = 'Compliance Policies' } + ) + } + Mock Invoke-IntuneCategoryScan { + [PSCustomObject]@{ + Records = @($record) + Errors = @([PSCustomObject]@{ CategoryId = 'CompliancePolicies'; Message = 'HTTP 403' }) + } + } + + $snapshot = Export-IntuneAssignmentSnapshot -Path $path -CapturedAtUtc $script:fixedCapture -PassThru + + $snapshot.Coverage.Mode | Should -BeExactly TenantScan + $snapshot.Coverage.Complete | Should -BeFalse + ($snapshot.Coverage.Categories | Where-Object CategoryId -eq CompliancePolicies).Status | Should -BeExactly Failed + $snapshot.Coverage.Errors[0].Message | Should -BeExactly 'HTTP 403' + (Read-IACAssignmentSnapshot -Path $path).Coverage.Complete | Should -BeFalse + Should -Invoke Get-IntuneCategoryDefinition -Exactly 1 -ParameterFilter { $Audience -eq 'Effective' } + Should -Invoke Invoke-IntuneCategoryScan -Exactly 1 -ParameterFilter { $BuildRecords } + } + + It 'treats null scanner records and errors as empty collections' { + $path = Join-Path $TestDrive 'null-scan.json' + Mock Get-IntuneCategoryDefinition { @() } + Mock Invoke-IntuneCategoryScan { [PSCustomObject]@{ Records = $null; Errors = $null } } + + $snapshot = Export-IntuneAssignmentSnapshot -Path $path -CapturedAtUtc $script:fixedCapture -PassThru + $loaded = Read-IACAssignmentSnapshot -Path $path + + $snapshot.Coverage.Complete | Should -BeTrue + @($loaded.Records).Count | Should -Be 0 + @($loaded.Coverage.Errors).Count | Should -Be 0 + } + + It 'marks skipped optional categories incomplete and excludes them from comparison' { + $baselinePath = Join-Path $TestDrive 'optional-baseline.json' + $currentPath = Join-Path $TestDrive 'optional-current.json' + $record = New-SnapshotTestRecord -CategoryId WindowsFeatureUpdates + $record | Export-IntuneAssignmentSnapshot -Path $baselinePath -CoverageCategory WindowsFeatureUpdates ` + -CoverageComplete -CapturedAtUtc $script:fixedCapture + Mock Get-IntuneCategoryDefinition { + @([PSCustomObject]@{ Id = 'WindowsFeatureUpdates'; DisplayName = 'Windows Feature Updates' }) + } + Mock Invoke-IntuneCategoryScan { + [PSCustomObject]@{ + Records = @() + Errors = @() + Skipped = @([PSCustomObject]@{ CategoryId = 'WindowsFeatureUpdates'; Message = 'HTTP 503' }) + } + } + + $snapshot = Export-IntuneAssignmentSnapshot -Path $currentPath -CapturedAtUtc $script:fixedCapture -PassThru + + $snapshot.Coverage.Complete | Should -BeFalse + $snapshot.Coverage.Categories[0].Status | Should -BeExactly Skipped + $snapshot.Coverage.Errors[0].Message | Should -BeExactly 'HTTP 503' + $removalChanges = @(Compare-IntuneAssignmentSnapshot -ReferencePath $baselinePath ` + -DifferencePath $currentPath -WarningVariable removalWarnings) + $additionChanges = @(Compare-IntuneAssignmentSnapshot -ReferencePath $currentPath ` + -DifferencePath $baselinePath -WarningVariable additionWarnings) + $removalChanges.Count | Should -Be 0 + $additionChanges.Count | Should -Be 0 + @($removalWarnings).Count | Should -Be 1 + "$removalWarnings" | Should -Match 'WindowsFeatureUpdates' + @($additionWarnings).Count | Should -Be 1 + } + + It 'rejects blank category ids and scan errors outside tenant coverage before writing' { + $blankPath = Join-Path $TestDrive 'blank-category.json' + Mock Get-IntuneCategoryDefinition { @([PSCustomObject]@{ Id = ''; DisplayName = 'Broken' }) } + Mock Invoke-IntuneCategoryScan { [PSCustomObject]@{ Records = @(); Errors = @() } } + { Export-IntuneAssignmentSnapshot -Path $blankPath -CapturedAtUtc $script:fixedCapture } | + Should -Throw '*category definition without Id*' + + $outsidePath = Join-Path $TestDrive 'outside-error.json' + Mock Get-IntuneCategoryDefinition { @([PSCustomObject]@{ Id = 'DeviceConfigurations'; DisplayName = 'Device Configurations' }) } + Mock Invoke-IntuneCategoryScan { + [PSCustomObject]@{ + Records = @() + Errors = @([PSCustomObject]@{ CategoryId = 'CompliancePolicies'; Message = 'HTTP 403' }) + } + } + { Export-IntuneAssignmentSnapshot -Path $outsidePath -CapturedAtUtc $script:fixedCapture } | + Should -Throw '*errors outside declared coverage*CompliancePolicies*' + } + + It 'rejects invalid reason-chain sequences with an actionable error' { + $path = Join-Path $TestDrive 'invalid-sequence.json' + $record = New-SnapshotTestRecord + $record.ReasonChain = @([PSCustomObject]@{ Sequence = 'first'; Code = 'Bad' }) + + { $record | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete -CapturedAtUtc $script:fixedCapture } | + Should -Throw "*reason-chain entry with invalid Sequence 'first'*" + } + + It 'supports empty snapshots and refuses accidental overwrites' { + $path = Join-Path $TestDrive 'empty.json' + + $snapshot = Export-IntuneAssignmentSnapshot -Path $path -InputObject @() ` + -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture -PassThru + + @($snapshot.Records).Count | Should -Be 0 + $snapshot.Coverage.RecordCount | Should -Be 0 + { Export-IntuneAssignmentSnapshot -Path $path -InputObject @() -CapturedAtUtc $script:fixedCapture } | + Should -Throw '*already exists*use -Force*' + } +} + +Describe 'Compare-IntuneAssignmentSnapshot' { + BeforeEach { + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + } + + It 'reports deterministic Added, Removed, and Changed assignment records' { + $beforePath = Join-Path $TestDrive 'before.json' + $afterPath = Join-Path $TestDrive 'after.json' + $before = @( + New-SnapshotTestRecord -PolicyId policy-change -AssignmentId assignment-change -Intent required + New-SnapshotTestRecord -PolicyId policy-remove -AssignmentId assignment-remove + ) + $after = @( + New-SnapshotTestRecord -PolicyId policy-change -AssignmentId assignment-change -Intent available + New-SnapshotTestRecord -PolicyId policy-add -AssignmentId assignment-add + ) + $before | Export-IntuneAssignmentSnapshot -Path $beforePath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + $after | Export-IntuneAssignmentSnapshot -Path $afterPath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + + $changes = @(Compare-IntuneAssignmentSnapshot -ReferencePath $beforePath -DifferencePath $afterPath) + + $changes.ChangeType | Should -Be @('Added', 'Removed', 'Changed') + $changes[0].PolicyId | Should -BeExactly policy-add + $changes[1].PolicyId | Should -BeExactly policy-remove + $changes[2].PolicyId | Should -BeExactly policy-change + $changes[2].ChangedFields | Should -Contain Intent + $changes[2].Before.Intent | Should -BeExactly required + $changes[2].After.Intent | Should -BeExactly available + $changes[0].PSObject.TypeNames | Should -Contain IntuneAssignmentChecker.AssignmentSnapshotDifference + } + + It 'detects changes in array-valued canonical fields' { + $beforePath = Join-Path $TestDrive 'arrays-before.json' + $afterPath = Join-Path $TestDrive 'arrays-after.json' + $before = New-SnapshotTestRecord + $before.ScopeTagIds = @('0', '1') + $before.ReasonChain = @( + [PSCustomObject]@{ Sequence = 1; Code = 'One' } + [PSCustomObject]@{ Sequence = 2; Code = 'Two' } + ) + $after = New-SnapshotTestRecord + $after.ScopeTagIds = @('0') + $after.ReasonChain = @([PSCustomObject]@{ Sequence = 1; Code = 'One' }) + $before | Export-IntuneAssignmentSnapshot -Path $beforePath -CoverageComplete -CapturedAtUtc $script:fixedCapture + $after | Export-IntuneAssignmentSnapshot -Path $afterPath -CoverageComplete -CapturedAtUtc $script:fixedCapture + + $change = Compare-IntuneAssignmentSnapshot -ReferencePath $beforePath -DifferencePath $afterPath + + $change.ChangeType | Should -BeExactly Changed + $change.ChangedFields | Should -Contain ScopeTagIds + $change.ChangedFields | Should -Contain ReasonChain + } + + It 'returns no differences for empty compatible snapshots' { + $beforePath = Join-Path $TestDrive 'empty-before.json' + $afterPath = Join-Path $TestDrive 'empty-after.json' + Export-IntuneAssignmentSnapshot -Path $beforePath -InputObject @() -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + Export-IntuneAssignmentSnapshot -Path $afterPath -InputObject @() -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + + @(Compare-IntuneAssignmentSnapshot -ReferencePath $beforePath -DifferencePath $afterPath).Count | Should -Be 0 + } + + It 'rejects malformed JSON and unsupported snapshot schemas with actionable errors' { + $malformedPath = Join-Path $TestDrive 'malformed.json' + $unsupportedPath = Join-Path $TestDrive 'unsupported.json' + $nonNumericVersionPath = Join-Path $TestDrive 'non-numeric-version.json' + $missingRecordsPath = Join-Path $TestDrive 'missing-records.json' + [System.IO.File]::WriteAllText($malformedPath, '{not-json') + [System.IO.File]::WriteAllText($unsupportedPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":2}') + [System.IO.File]::WriteAllText($nonNumericVersionPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":"v1"}') + [System.IO.File]::WriteAllText($missingRecordsPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":1,"CapturedAtUtc":"2026-08-01T10:00:00Z","ModuleVersion":"4.4.0","Tenant":{},"Coverage":{}}') + + { Read-IACAssignmentSnapshot -Path $malformedPath } | Should -Throw '*not valid JSON*' + { Read-IACAssignmentSnapshot -Path $unsupportedPath } | Should -Throw '*unsupported schema version*expected version 1*' + { Read-IACAssignmentSnapshot -Path $nonNumericVersionPath } | Should -Throw '*unsupported schema version*expected version 1*' + { Read-IACAssignmentSnapshot -Path $missingRecordsPath } | Should -Throw "*missing 'Records'*" + } + + It 'rejects non-canonical coverage mode and status casing' { + $path = Join-Path $TestDrive 'coverage-casing.json' + New-SnapshotTestRecord | Export-IntuneAssignmentSnapshot -Path $path -CoverageComplete ` + -CapturedAtUtc $script:fixedCapture + $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 20 + $parsed.Coverage.Categories[0].Status = 'provided' + $parsed | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $path + + { Read-IACAssignmentSnapshot -Path $path } | Should -Throw '*unsupported coverage status*provided*' + + $parsed.Coverage.Categories[0].Status = 'Provided' + $parsed.Coverage.Mode = 'providedrecords' + $parsed | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $path + + { Read-IACAssignmentSnapshot -Path $path } | Should -Throw '*unsupported Coverage.Mode*providedrecords*' + } + + It 'rejects cross-tenant, incomplete, and mismatched coverage by default' { + $tenantOnePath = Join-Path $TestDrive 'tenant-one.json' + $tenantTwoPath = Join-Path $TestDrive 'tenant-two.json' + $recordOne = New-SnapshotTestRecord + $recordOne | Export-IntuneAssignmentSnapshot -Path $tenantOnePath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc $script:fixedCapture + + $script:CurrentTenantId = 'tenant-2' + $script:CurrentTenantName = 'Fabrikam' + $recordTwo = New-SnapshotTestRecord -CategoryId CompliancePolicies + $recordTwo | Export-IntuneAssignmentSnapshot -Path $tenantTwoPath -CoverageCategory CompliancePolicies -CoverageComplete -CapturedAtUtc $script:fixedCapture + + { Compare-IntuneAssignmentSnapshot -ReferencePath $tenantOnePath -DifferencePath $tenantTwoPath } | + Should -Throw '*different tenants*' + + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Contoso' + $parsed = Get-Content -LiteralPath $tenantOnePath -Raw | ConvertFrom-Json -Depth 20 + $parsed.Coverage.Complete = $false + $parsed | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tenantOnePath + { Compare-IntuneAssignmentSnapshot -ReferencePath $tenantOnePath -DifferencePath $tenantOnePath } | + Should -Throw '*incomplete category coverage*' + + $parsed.Coverage.Complete = $true + $parsed | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tenantOnePath + $coverageTwoPath = Join-Path $TestDrive 'coverage-two.json' + $recordOne | Export-IntuneAssignmentSnapshot -Path $coverageTwoPath ` + -CoverageCategory DeviceConfigurations, CompliancePolicies -CoverageComplete -CapturedAtUtc $script:fixedCapture + { Compare-IntuneAssignmentSnapshot -ReferencePath $tenantOnePath -DifferencePath $coverageTwoPath } | + Should -Throw '*different category sets*' + } +} + +Describe 'Assignment snapshot public surface' { + It 'exports both snapshot commands from the module manifest' { + $manifest = Test-ModuleManifest (Join-Path $moduleRoot 'IntuneAssignmentChecker.psd1') + $manifest.ExportedFunctions.Keys | Should -Contain Export-IntuneAssignmentSnapshot + $manifest.ExportedFunctions.Keys | Should -Contain Compare-IntuneAssignmentSnapshot + } +} diff --git a/Tests/Unit/CategoryScan.Tests.ps1 b/Tests/Unit/CategoryScan.Tests.ps1 index 9770fac..e1bdb2f 100644 --- a/Tests/Unit/CategoryScan.Tests.ps1 +++ b/Tests/Unit/CategoryScan.Tests.ps1 @@ -204,7 +204,7 @@ Describe 'Invoke-IntuneCategoryScan' { Should -Invoke Write-Error -Exactly 1 } - It 'skips OptionalFeature category failures quietly without an error record' { + It 'reports OptionalFeature category failures as skipped without an error record' { Mock Get-IntuneEntities { if ($EntityType -eq 'virtualEndpoint/provisioningPolicies') { throw 'not licensed' } return @([PSCustomObject]@{ id = 'comp-1'; displayName = 'Compliance 1' }) @@ -223,6 +223,9 @@ Describe 'Invoke-IntuneCategoryScan' { $script:processedIds | Should -Be @('comp-1') $result.Errors.Count | Should -Be 0 + $result.Skipped.Count | Should -Be 1 + $result.Skipped[0].CategoryId | Should -BeExactly 'CloudPCProvisioningPolicies' + $result.Skipped[0].Message | Should -Match 'not licensed' Should -Invoke Write-Error -Exactly 0 } } From d4c2a1d0cebf25eacf19969828bba86bff31c57f Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:46:08 +0200 Subject: [PATCH 7/8] Harden CI and release validation (#142) --- .github/workflows/pester.yml | 52 ++- .github/workflows/psscriptanalyzer.yml | 323 +++++------------- .github/workflows/publish-module.yml | 75 +++- .../IntuneAssignmentChecker.psd1 | 2 + Tests/Fixtures/GraphTransport.json | 53 +++ Tests/README.md | 28 +- Tests/Release/ModulePackage.Tests.ps1 | 70 ++++ Tests/Unit/GraphTransport.Tests.ps1 | 113 ++++-- 8 files changed, 432 insertions(+), 284 deletions(-) create mode 100644 Tests/Fixtures/GraphTransport.json create mode 100644 Tests/Release/ModulePackage.Tests.ps1 diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index 4e07cee..a93b300 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -15,10 +15,14 @@ on: - '.github/workflows/pester.yml' workflow_dispatch: +permissions: + contents: read + jobs: test: name: Pester on ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -26,7 +30,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Pester 5 shell: pwsh @@ -49,16 +53,54 @@ jobs: $config.TestResult.OutputFormat = 'NUnitXml' $config.TestResult.OutputPath = './Tests/TestResults.xml' $result = Invoke-Pester -Configuration $config - if ($result.FailedCount -gt 0) { - Write-Error "Pester: $($result.FailedCount) test(s) failed." - exit 1 + if ($result.Result -ne 'Passed' -or $result.FailedCount -gt 0 -or $result.TotalCount -eq 0) { + throw "Pester failed: Result=$($result.Result), Failed=$($result.FailedCount), Total=$($result.TotalCount)." } Write-Host "Pester: $($result.PassedCount) test(s) passed." - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pester-results-${{ matrix.os }} path: Tests/TestResults.xml if-no-files-found: warn + + validate-package: + name: Validate module package + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install validation dependencies + shell: pwsh + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -SkipPublisherCheck + Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force + + - name: Validate manifest, package, and exports + shell: pwsh + run: | + $config = New-PesterConfiguration + $config.Run.Path = './Tests/Release' + $config.Run.PassThru = $true + $config.Output.Verbosity = 'Detailed' + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = './Tests/PackageTestResults.xml' + $result = Invoke-Pester -Configuration $config + if ($result.Result -ne 'Passed' -or $result.FailedCount -gt 0 -or $result.TotalCount -eq 0) { + throw "Package validation failed: Result=$($result.Result), Failed=$($result.FailedCount), Total=$($result.TotalCount)." + } + + - name: Upload package validation results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-validation-results + path: Tests/PackageTestResults.xml + if-no-files-found: warn diff --git a/.github/workflows/psscriptanalyzer.yml b/.github/workflows/psscriptanalyzer.yml index 9b460fa..1fd13c9 100644 --- a/.github/workflows/psscriptanalyzer.yml +++ b/.github/workflows/psscriptanalyzer.yml @@ -2,14 +2,20 @@ name: PSScriptAnalyzer on: push: - branches: [ main ] + branches: [main] paths: - - '**.ps1' + - 'Module/**' + - 'Tests/**' + - 'Register-IntuneAssignmentCheckerApp.ps1' + - '.PSScriptAnalyzerSettings.psd1' - '.github/workflows/psscriptanalyzer.yml' pull_request: - branches: [ main ] + branches: [main] paths: - - '**.ps1' + - 'Module/**' + - 'Tests/**' + - 'Register-IntuneAssignmentCheckerApp.ps1' + - '.PSScriptAnalyzerSettings.psd1' - '.github/workflows/psscriptanalyzer.yml' workflow_dispatch: @@ -21,288 +27,121 @@ jobs: analyze: name: PSScriptAnalyzer runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install PSScriptAnalyzer shell: pwsh run: | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser - Write-Host "PSScriptAnalyzer version: $(Get-Module -Name PSScriptAnalyzer -ListAvailable | Select-Object -ExpandProperty Version)" + Import-Module PSScriptAnalyzer -ErrorAction Stop + Get-Module PSScriptAnalyzer | Format-List Name, Version - name: Run PSScriptAnalyzer id: analysis shell: pwsh run: | - # Initialize counters - $errorCount = 0 - $warningCount = 0 - $infoCount = 0 - - Write-Host "============================================" - Write-Host "Running PSScriptAnalyzer on Module files" - Write-Host "============================================" - Write-Host "" - - # Run the analysis on the module directory (all .ps1 and .psm1 files) - # -RecurseCustomRulePath omitted: no custom rules, and the switch can trigger - # internal NREs in PSScriptAnalyzer 1.25 on Linux when no -CustomRulePath is set. - try { - $results = Invoke-ScriptAnalyzer -Path ./Module/IntuneAssignmentChecker -Recurse -Settings ./.PSScriptAnalyzerSettings.psd1 -ErrorAction Stop - } catch { - Write-Host "Recursive analysis threw: $($_.Exception.Message)" -ForegroundColor Yellow - Write-Host "Falling back to per-file analysis..." -ForegroundColor Yellow - $results = @() - $files = Get-ChildItem -Path ./Module/IntuneAssignmentChecker -Recurse -Include *.ps1,*.psm1 -File - foreach ($f in $files) { - try { - $results += Invoke-ScriptAnalyzer -Path $f.FullName -Settings ./.PSScriptAnalyzerSettings.psd1 -ErrorAction Stop - } catch { - Write-Host " Skipped $($f.Name): $($_.Exception.Message)" -ForegroundColor DarkYellow - } - } - } - - if ($results) { - # Group by severity - $grouped = $results | Group-Object -Property Severity - - # Count by severity - foreach ($group in $grouped) { - switch ($group.Name) { - 'Error' { $errorCount = $group.Count } - 'Warning' { $warningCount = $group.Count } - 'Information' { $infoCount = $group.Count } - } - } - - Write-Host "๐Ÿ“Š Analysis Summary" - Write-Host "===================" - Write-Host "โŒ Errors: $errorCount" - Write-Host "โš ๏ธ Warnings: $warningCount" - Write-Host "โ„น๏ธ Information: $infoCount" - Write-Host "" - - # Display errors first - $errors = $results | Where-Object { $_.Severity -eq 'Error' } - if ($errors) { - Write-Host "โŒ ERRORS" -ForegroundColor Red - Write-Host "========" -ForegroundColor Red - foreach ($err in $errors) { - Write-Host "Line $($err.Line): [$($err.RuleName)] $($err.Message)" -ForegroundColor Red - } - Write-Host "" + $files = @( + Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1 + Get-ChildItem ./Tests -Recurse -File -Include *.ps1 + Get-Item ./Register-IntuneAssignmentCheckerApp.ps1 + ) | Sort-Object FullName + $results = @( + foreach ($file in $files) { + Invoke-ScriptAnalyzer -Path $file.FullName ` + -Settings ./.PSScriptAnalyzerSettings.psd1 -ErrorAction Stop } - - # Display warnings - $warnings = $results | Where-Object { $_.Severity -eq 'Warning' } - if ($warnings) { - Write-Host "โš ๏ธ WARNINGS" -ForegroundColor Yellow - Write-Host "==========" -ForegroundColor Yellow - - # Group warnings by rule for better readability - $warningGroups = $warnings | Group-Object -Property RuleName | Sort-Object Count -Descending - - foreach ($group in $warningGroups) { - Write-Host "" - Write-Host " Rule: $($group.Name) (Count: $($group.Count))" -ForegroundColor Cyan - - # Show first 5 examples of each warning type - $examples = $group.Group | Select-Object -First 5 - foreach ($warning in $examples) { - Write-Host " Line $($warning.Line): $($warning.Message)" -ForegroundColor Yellow - } - - if ($group.Count -gt 5) { - Write-Host " ... and $($group.Count - 5) more instances" -ForegroundColor DarkGray - } - } - Write-Host "" + ) + $analysisErrors = @($results | Where-Object Severity -eq Error) + $parseErrors = @($results | Where-Object Severity -eq ParseError) + $errors = @($analysisErrors) + @($parseErrors) + $warnings = @($results | Where-Object Severity -eq Warning) + $information = @($results | Where-Object Severity -eq Information) + + ConvertTo-Json -InputObject @($results) -Depth 5 | + Set-Content -LiteralPath analysis-results.json -Encoding utf8 + "error_count=$($errors.Count)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "warning_count=$($warnings.Count)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "info_count=$($information.Count)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + $report = @( + '# PSScriptAnalyzer Report' + '' + "Analyzed $($files.Count) PowerShell source files." + '' + '| Severity | Count |' + '|---|---:|' + "| Error | $($analysisErrors.Count) |" + "| ParseError | $($parseErrors.Count) |" + "| Warning | $($warnings.Count) |" + "| Information | $($information.Count) |" + ) + if ($results.Count -gt 0) { + $report += @('', '## Findings', '') + foreach ($result in $results) { + $relativePath = [System.IO.Path]::GetRelativePath($PWD.Path, $result.ScriptPath) + $message = "$($result.Message)" -replace '[\r\n]+', ' ' + $report += "- **$($result.Severity)** ``$($relativePath):$($result.Line)`` [$($result.RuleName)] $message" } - - # Display information - $info = $results | Where-Object { $_.Severity -eq 'Information' } - if ($info -and $env:SHOW_INFO -eq 'true') { - Write-Host "โ„น๏ธ INFORMATION" -ForegroundColor Blue - Write-Host "=============" -ForegroundColor Blue - foreach ($item in $info) { - Write-Host "Line $($item.Line): [$($item.RuleName)] $($item.Message)" -ForegroundColor Blue - } - Write-Host "" - } - - # Export results for artifact - $results | ConvertTo-Json -Depth 5 | Out-File -FilePath analysis-results.json - - # Set outputs for later steps - echo "error_count=$errorCount" >> $env:GITHUB_OUTPUT - echo "warning_count=$warningCount" >> $env:GITHUB_OUTPUT - echo "info_count=$infoCount" >> $env:GITHUB_OUTPUT - echo "has_errors=$($errorCount -gt 0)" >> $env:GITHUB_OUTPUT - - # Exit with error if errors found (configurable) - if ($errorCount -gt 0 -and $env:FAIL_ON_ERROR -eq 'true') { - Write-Host "โŒ Analysis failed due to errors" -ForegroundColor Red - exit 1 - } - } else { - Write-Host "โœ… No issues found! The script passes all PSScriptAnalyzer rules." -ForegroundColor Green - echo "error_count=0" >> $env:GITHUB_OUTPUT - echo "warning_count=0" >> $env:GITHUB_OUTPUT - echo "info_count=0" >> $env:GITHUB_OUTPUT - echo "has_errors=false" >> $env:GITHUB_OUTPUT } - env: - FAIL_ON_ERROR: false # Set to true if you want the workflow to fail on errors - SHOW_INFO: false # Set to true to show information level issues - - - name: Generate Detailed Report - if: always() - shell: pwsh - run: | - # Generate a detailed markdown report - $reportContent = @" - # PSScriptAnalyzer Report - - **Module:** IntuneAssignmentChecker - **Date:** $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - **Analyzer Version:** $(Get-Module -Name PSScriptAnalyzer -ListAvailable | Select-Object -ExpandProperty Version) - - ## Summary + $report | Set-Content -LiteralPath analysis-report.md -Encoding utf8 + $report | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + $results | Format-Table Severity, RuleName, ScriptName, Line, Message -Wrap - | Severity | Count | - |----------|-------| - | โŒ Errors | ${{ steps.analysis.outputs.error_count }} | - | โš ๏ธ Warnings | ${{ steps.analysis.outputs.warning_count }} | - | โ„น๏ธ Information | ${{ steps.analysis.outputs.info_count }} | - - "@ - - if (Test-Path analysis-results.json) { - $results = Get-Content analysis-results.json | ConvertFrom-Json - - if ($results) { - # Add detailed findings grouped by rule - $reportContent += "`n## Detailed Findings by Rule`n`n" - - $grouped = $results | Group-Object -Property RuleName, Severity - - foreach ($group in ($grouped | Sort-Object { $_.Group[0].Severity }, Name)) { - $severity = $group.Group[0].Severity - $icon = switch ($severity) { - 'Error' { 'โŒ' } - 'Warning' { 'โš ๏ธ' } - 'Information' { 'โ„น๏ธ' } - default { 'โ“' } - } - - $reportContent += "### $icon $($group.Name -replace ',.*') ($($group.Count) instances)`n`n" - - # Show up to 10 examples - $examples = $group.Group | Select-Object -First 10 - $reportContent += "| Line | Message |`n|------|---------|`n" - - foreach ($item in $examples) { - $message = $item.Message -replace '\|', '\|' -replace '\n', ' ' - if ($message.Length -gt 100) { - $message = $message.Substring(0, 97) + "..." - } - $reportContent += "| $($item.Line) | $message |`n" - } - - if ($group.Count -gt 10) { - $reportContent += "`n*... and $($group.Count - 10) more instances*`n" - } - - $reportContent += "`n" - } - } else { - $reportContent += "`n## โœ… No Issues Found`n`nThe script passes all PSScriptAnalyzer rules!`n" - } + if ($errors.Count -gt 0) { + throw "PSScriptAnalyzer found $($errors.Count) error(s)." } - # Write report to file - $reportContent | Out-File -FilePath analysis-report.md - - # Also output to job summary - $reportContent >> $env:GITHUB_STEP_SUMMARY - - - name: Upload Analysis Results + - name: Upload analysis results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: psscriptanalyzer-results path: | analysis-results.json analysis-report.md + if-no-files-found: warn retention-days: 30 - - name: Comment PR (if applicable) + - name: Comment on pull request if: github.event_name == 'pull_request' && always() - uses: actions/github-script@v7 + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ERROR_COUNT: ${{ steps.analysis.outputs.error_count }} + WARNING_COUNT: ${{ steps.analysis.outputs.warning_count }} with: - github-token: ${{secrets.GITHUB_TOKEN}} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const fs = require('fs'); - - // Read the report - let report = '## PSScriptAnalyzer Results\n\n'; - - const errorCount = '${{ steps.analysis.outputs.error_count }}'; - const warningCount = '${{ steps.analysis.outputs.warning_count }}'; - - if (errorCount === '0' && warningCount === '0') { - report += 'โœ… **All checks passed!** No issues found.'; - } else { - report += `Found **${errorCount}** error(s) and **${warningCount}** warning(s).\n\n`; - report += 'See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'; - } - - // Find and update or create comment - const { data: comments } = await github.rest.issues.listComments({ + const errorCount = process.env.ERROR_COUNT || 'unknown'; + const warningCount = process.env.WARNING_COUNT || 'unknown'; + const marker = ''; + const body = `${marker}\n## PSScriptAnalyzer Results\n\nErrors: **${errorCount}** ยท Warnings: **${warningCount}**\n\n[Open workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + per_page: 100, }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('PSScriptAnalyzer Results') + const previous = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(marker) ); - - if (botComment) { + if (previous) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: botComment.id, - body: report + comment_id: previous.id, + body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: report + body, }); } - - - name: Set Status Check - if: always() - shell: pwsh - run: | - $hasErrors = "${{ steps.analysis.outputs.has_errors }}" - $errorCount = "${{ steps.analysis.outputs.error_count }}" - $warningCount = "${{ steps.analysis.outputs.warning_count }}" - - if ($hasErrors -eq 'true') { - Write-Host "โŒ Status: Failed - Found $errorCount error(s)" -ForegroundColor Red - # Uncomment the next line to fail the workflow on errors - # exit 1 - } elseif ($warningCount -gt 0) { - Write-Host "โš ๏ธ Status: Passed with warnings - Found $warningCount warning(s)" -ForegroundColor Yellow - } else { - Write-Host "โœ… Status: Passed - No issues found" -ForegroundColor Green - } \ No newline at end of file diff --git a/.github/workflows/publish-module.yml b/.github/workflows/publish-module.yml index 0224a92..828314b 100644 --- a/.github/workflows/publish-module.yml +++ b/.github/workflows/publish-module.yml @@ -5,24 +5,85 @@ on: types: [published] workflow_dispatch: +permissions: + contents: read + +concurrency: + group: publish-powershell-module + cancel-in-progress: false + jobs: test-and-publish: + name: Validate and publish runs-on: ubuntu-latest + timeout-minutes: 30 + steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install validation dependencies + shell: pwsh + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -SkipPublisherCheck + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force + + - name: Validate publish tag + shell: pwsh + env: + PUBLISH_TAG: ${{ github.event.release.tag_name || github.ref_name }} + PUBLISH_REF_TYPE: ${{ github.ref_type }} + run: | + if ('${{ github.event_name }}' -eq 'workflow_dispatch' -and $env:PUBLISH_REF_TYPE -cne 'tag') { + throw 'Manual publishing must be dispatched from a version tag.' + } + $manifest = Import-PowerShellDataFile ./Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 + $tagVersion = $env:PUBLISH_TAG -replace '^v', '' + if ($tagVersion -cne "$($manifest.ModuleVersion)") { + throw "Publish tag '$($env:PUBLISH_TAG)' does not match module version '$($manifest.ModuleVersion)'." + } + + - name: Run PSScriptAnalyzer release gate + shell: pwsh + run: | + $files = @( + Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1 + Get-ChildItem ./Tests -Recurse -File -Include *.ps1 + Get-Item ./Register-IntuneAssignmentCheckerApp.ps1 + ) | Sort-Object FullName + $results = @( + foreach ($file in $files) { + Invoke-ScriptAnalyzer -Path $file.FullName ` + -Settings ./.PSScriptAnalyzerSettings.psd1 -ErrorAction Stop + } + ) + $results | Format-Table Severity, RuleName, ScriptName, Line, Message -Wrap + $errors = @($results | Where-Object { $_.Severity -in @('Error', 'ParseError') }) + if ($errors.Count -gt 0) { + throw "PSScriptAnalyzer found $($errors.Count) error(s)." + } - - name: Validate module loads + - name: Run unit and package tests shell: pwsh run: | - Import-Module ./Module/IntuneAssignmentChecker -Force -ErrorAction Stop - $commands = Get-Command -Module IntuneAssignmentChecker - Write-Host "Module loaded successfully with $($commands.Count) exported commands" - $commands | Sort-Object Name | ForEach-Object { Write-Host " - $($_.Name)" } + $config = New-PesterConfiguration + $config.Run.Path = @('./Tests/Unit', './Tests/Release') + $config.Run.PassThru = $true + $config.Output.Verbosity = 'Detailed' + $result = Invoke-Pester -Configuration $config + if ($result.Result -ne 'Passed' -or $result.FailedCount -gt 0 -or $result.TotalCount -eq 0) { + throw "Release validation failed: Result=$($result.Result), Failed=$($result.FailedCount), Total=$($result.TotalCount)." + } - name: Publish to PowerShell Gallery shell: pwsh env: PS_GALLERY_API_KEY: ${{ secrets.NUGET_KEY }} run: | - Publish-Module -Path ./Module/IntuneAssignmentChecker -Repository PSGallery -NuGetApiKey $env:PS_GALLERY_API_KEY + if ([string]::IsNullOrWhiteSpace($env:PS_GALLERY_API_KEY)) { + throw 'The NUGET_KEY repository secret is required to publish the module.' + } + Publish-Module -Path ./Module/IntuneAssignmentChecker -Repository PSGallery ` + -NuGetApiKey $env:PS_GALLERY_API_KEY -ErrorAction Stop diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index c1f14e6..a0aa329 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -49,11 +49,13 @@ IconUri = '' ReleaseNotes = @' Version 4.4.0: +- Centralize every Microsoft Graph call behind a beta-only transport with automatic paging, bounded retry/backoff, nextLink validation, and structured error metadata (issue #136). - Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). - Cover Windows Feature Update, Quality Update, Driver Update, and Quality Update policy assignments across shared scans, searches, comparisons, exports, and reports (issue #138). - Add Test-IntuneAssignmentFilter for safe, local tri-state evaluation of documented managed-device filter rules without executing tenant-provided text (issue #139). - Add Get-IntuneEffectiveAssignment with user/device targeting precedence, filter evaluation, machine-readable reason chains, PassThru, and CSV output (issue #140). - Add deterministic, schema-versioned assignment snapshots and stable Added/Removed/Changed drift comparison (issue #141). +- Turn analyzer, cross-platform Pester, module-package, export-contract, and pre-publish validation into release gates; update GitHub Actions to supported runtimes (issue #142). Version 4.3.2: - Recognize Microsoft 365 (Unified) groups as first-class Intune assignment targets and expose group type, membership mode, and mail address in group checks and exports (issue #128). diff --git a/Tests/Fixtures/GraphTransport.json b/Tests/Fixtures/GraphTransport.json new file mode 100644 index 0000000..30ee60f --- /dev/null +++ b/Tests/Fixtures/GraphTransport.json @@ -0,0 +1,53 @@ +{ + "pages": { + "first": { + "@odata.context": "https://graph.test/beta/$metadata#groups", + "value": [ + { + "id": "group-1", + "displayName": "First Group" + } + ], + "@odata.nextLink": "https://graph.test/beta/groups?$skiptoken=fixture-page-2" + }, + "second": { + "value": [ + { + "id": "group-2", + "displayName": "Second Group" + } + ] + } + }, + "errors": { + "badRequest": { + "statusCode": 400, + "code": "BadRequest", + "message": "The request is not valid.", + "requestId": "request-400", + "clientRequestId": "client-400" + }, + "forbidden": { + "statusCode": 403, + "code": "Authorization_RequestDenied", + "message": "Insufficient privileges to complete the operation.", + "requestId": "request-403", + "clientRequestId": "client-403" + }, + "throttled": { + "statusCode": 429, + "code": "TooManyRequests", + "message": "Too many requests.", + "requestId": "request-429", + "clientRequestId": "client-429", + "retryAfter": 7 + }, + "serviceUnavailable": { + "statusCode": 503, + "code": "ServiceUnavailable", + "message": "The service is temporarily unavailable.", + "requestId": "request-503", + "clientRequestId": "client-503" + } + } +} diff --git a/Tests/README.md b/Tests/README.md index 54adb49..4c621db 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -1,11 +1,11 @@ # IntuneAssignmentChecker Tests -Two layers, matched to actual risk. +Three layers, matched to actual risk. -## Layer 1: Unit tests (`Tests/Unit/`) +## Layer 1: Unit and contract tests (`Tests/Unit/`) Pure-logic Pester tests for the private helpers. No Graph calls, no auth, no -network. Runs in well under a second. +network. The full suite normally completes in under a minute. **What it covers:** - `Format-AssignmentFilter` - filter string formatting across include/exclude/none/unknown @@ -21,6 +21,9 @@ network. Runs in well under a second. and README use the least-privilege `GroupMember.Read.All` application role - Group membership Graph helpers - verifies the transitive group membership endpoint and pagination behavior +- Graph transport fixtures - verify beta paging plus structured 400, 403, 429, + and 5xx handling, retry limits, throttling delays, and output contracts + using `Tests/Fixtures/GraphTransport.json` **Why these tests matter:** most regressions in this codebase are string-format changes that slip past static analysis. Unit tests at this layer catch them. @@ -38,7 +41,24 @@ Runs automatically on push and PR to `main` when any file under `Module/` or `Tests/` changes. Matrix is Ubuntu / Windows / macOS, all on PowerShell 7. See `.github/workflows/pester.yml`. -## Layer 2: Smoke test (`Tests/Smoke/Run-Smoke.ps1`) +## Layer 2: Release package tests (`Tests/Release/`) + +The release suite validates the module manifest, version and release notes, +declared package files, the one-to-one mapping between `Public/*.ps1` and +`FunctionsToExport`, and the commands/alias exposed by an actual package-path +import. CI runs this as a separate Ubuntu gate after installing the declared +Microsoft Graph dependency. The PowerShell Gallery workflow repeats unit, +package, and PSScriptAnalyzer gates before publishing, and release tags must +match `ModuleVersion` exactly. + +### Run locally + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser +Invoke-Pester ./Tests/Release +``` + +## Layer 3: Smoke test (`Tests/Smoke/Run-Smoke.ps1`) Read-only live Graph calls against a real tenant. Run manually by the maintainer before tagging a release. Catches broken Graph URLs, missing diff --git a/Tests/Release/ModulePackage.Tests.ps1 b/Tests/Release/ModulePackage.Tests.ps1 new file mode 100644 index 0000000..977c0f9 --- /dev/null +++ b/Tests/Release/ModulePackage.Tests.ps1 @@ -0,0 +1,70 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path + $moduleRoot = Join-Path $repoRoot 'Module/IntuneAssignmentChecker' + $manifestPath = Join-Path $moduleRoot 'IntuneAssignmentChecker.psd1' + $manifestData = Import-PowerShellDataFile -Path $manifestPath + $manifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Stop + + function Get-OrdinalSortedString { + param([object[]]$InputObject) + $values = [System.Collections.Generic.List[string]]::new() + foreach ($item in @($InputObject)) { + if ($null -ne $item) { [void]$values.Add("$item") } + } + $values.Sort([System.StringComparer]::Ordinal) + return , $values.ToArray() + } + + $publicFunctionNames = @(Get-ChildItem (Join-Path $moduleRoot 'Public') -File -Filter '*.ps1' | + ForEach-Object BaseName) + $publicFunctionNames = @(Get-OrdinalSortedString -InputObject $publicFunctionNames) + $manifestFunctionNames = @(Get-OrdinalSortedString -InputObject $manifestData.FunctionsToExport) +} + +AfterAll { + Remove-Module IntuneAssignmentChecker -Force -ErrorAction SilentlyContinue +} + +Describe 'IntuneAssignmentChecker release package' { + It 'has a valid supported module version and matching release notes' { + $manifest.Name | Should -BeExactly IntuneAssignmentChecker + $manifest.Version.ToString() | Should -BeExactly "$($manifestData.ModuleVersion)" + $manifest.Version | Should -BeGreaterOrEqual ([version]'4.4.0') + $manifestData.PowerShellVersion | Should -BeExactly '7.0' + $releaseHeading = '(?m)^Version ' + [regex]::Escape("$($manifestData.ModuleVersion)") + ':' + $manifestData.PrivateData.PSData.ReleaseNotes | Should -Match $releaseHeading + } + + It 'declares every public function exactly once with no wildcard exports' { + $manifestData.FunctionsToExport | Should -Not -Contain '*' + @($manifestFunctionNames | Select-Object -Unique).Count | Should -Be $manifestFunctionNames.Count + ($manifestFunctionNames -join "`n") | Should -BeExactly ($publicFunctionNames -join "`n") + } + + It 'references package files that exist inside the module root' { + Test-Path -LiteralPath (Join-Path $moduleRoot $manifestData.RootModule) -PathType Leaf | Should -BeTrue + foreach ($relativePath in @($manifestData.FormatsToProcess) + @($manifestData.FileList)) { + $resolvedFile = Join-Path $moduleRoot $relativePath + $moduleBoundary = [System.IO.Path]::GetFullPath($moduleRoot) + [System.IO.Path]::DirectorySeparatorChar + [System.IO.Path]::GetFullPath($resolvedFile).StartsWith( + $moduleBoundary, + [System.StringComparison]::Ordinal + ) | Should -BeTrue + Test-Path -LiteralPath $resolvedFile -PathType Leaf | Should -BeTrue -Because "$relativePath is declared in the manifest" + } + } + + It 'imports from the package path and exposes only the manifest contract' { + $importedModule = Import-Module $manifestPath -Force -PassThru -ErrorAction Stop + $importedFunctions = @(Get-OrdinalSortedString -InputObject $importedModule.ExportedFunctions.Keys) + $importedAliases = @(Get-OrdinalSortedString -InputObject $importedModule.ExportedAliases.Keys) + + ($importedFunctions -join "`n") | Should -BeExactly ($manifestFunctionNames -join "`n") + $importedAliases | Should -BeExactly @('IntuneAssignmentChecker') + (Get-Command IntuneAssignmentChecker -CommandType Alias).Definition | + Should -BeExactly Invoke-IntuneAssignmentChecker + } +} diff --git a/Tests/Unit/GraphTransport.Tests.ps1 b/Tests/Unit/GraphTransport.Tests.ps1 index d04e9b6..75550b1 100644 --- a/Tests/Unit/GraphTransport.Tests.ps1 +++ b/Tests/Unit/GraphTransport.Tests.ps1 @@ -3,6 +3,8 @@ BeforeAll { $moduleRoot = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker' + $transportFixture = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../Fixtures/GraphTransport.json') ` + -Raw | ConvertFrom-Json -Depth 20 function Invoke-MgGraphRequest { param([string]$Uri, [string]$Method, [object]$Body, [string]$ErrorAction) @@ -14,6 +16,45 @@ BeforeAll { . (Join-Path $moduleRoot 'Private/Invoke-IACGraphRequest.ps1') . (Join-Path $moduleRoot 'Private/Get-IntuneEntities.ps1') + + function New-GraphFixtureErrorRecord { + param( + [Parameter(Mandatory)] + [object]$Fixture, + + [Parameter(Mandatory)] + [string]$Uri + ) + + $exception = [System.Net.Http.HttpRequestException]::new("$($Fixture.message)") + $exception | Add-Member -NotePropertyName StatusCode ` + -NotePropertyValue ([System.Net.HttpStatusCode][int]$Fixture.statusCode) -Force + if ($null -ne $Fixture.retryAfter) { + $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([PSCustomObject]@{ + Headers = [PSCustomObject]@{ 'Retry-After' = "$($Fixture.retryAfter)" } + }) + } + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'FixtureGraphFailure', + [System.Management.Automation.ErrorCategory]::InvalidOperation, + $Uri + ) + $payload = [ordered]@{ + error = [ordered]@{ + code = $Fixture.code + message = $Fixture.message + innerError = [ordered]@{ + 'request-id' = $Fixture.requestId + 'client-request-id' = $Fixture.clientRequestId + } + } + } + $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new( + (ConvertTo-Json -InputObject $payload -Depth 10 -Compress) + ) + return $record + } } Describe 'Get-IntuneEntities optional workload diagnostics' { @@ -78,20 +119,17 @@ Describe 'Invoke-IACGraphRequest' { } It 'follows beta nextLink pages when AllPages is requested' { + $firstPage = $transportFixture.pages.first + $secondPage = $transportFixture.pages.second Mock Invoke-MgGraphRequest { - if ($Uri -like '*skiptoken=next') { - return @{ value = @([PSCustomObject]@{ id = 'two' }) } - } - return @{ - value = @([PSCustomObject]@{ id = 'one' }) - '@odata.nextLink' = 'https://graph.test/beta/groups?$skiptoken=next' - } + if ($Uri -like '*skiptoken=fixture-page-2') { return $secondPage } + return $firstPage } $result = Invoke-IACGraphRequest -Uri '/groups' -AllPages $result -is [array] | Should -BeTrue - $result.id | Should -Be @('one', 'two') + $result.id | Should -Be @('group-1', 'group-2') Should -Invoke Invoke-MgGraphRequest -Exactly 2 } @@ -116,9 +154,12 @@ Describe 'Invoke-IACGraphRequest' { } It 'retries transient responses and succeeds' { + $throttled = $transportFixture.errors.throttled Mock Invoke-MgGraphRequest { $script:requestCount++ - if ($script:requestCount -lt 3) { throw 'HTTP 429 Too Many Requests' } + if ($script:requestCount -lt 3) { + throw (New-GraphFixtureErrorRecord -Fixture $throttled -Uri $Uri) + } @{ value = @([PSCustomObject]@{ id = 'ok' }) } } @@ -129,6 +170,25 @@ Describe 'Invoke-IACGraphRequest' { Should -Invoke Start-Sleep -Exactly 2 } + It 'does not retry a fixture-backed HTTP 400 and preserves its output error contract' { + $badRequest = $transportFixture.errors.badRequest + Mock Invoke-MgGraphRequest { + throw (New-GraphFixtureErrorRecord -Fixture $badRequest -Uri $Uri) + } + + $caught = $null + try { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 3 } + catch { $caught = $_ } + + $caught.FullyQualifiedErrorId | Should -Match '^IntuneAssignmentChecker.GraphRequestFailed' + $caught.Exception.Data['StatusCode'] | Should -Be 400 + $caught.Exception.Data['GraphErrorCode'] | Should -BeExactly BadRequest + $caught.Exception.Data['RequestId'] | Should -BeExactly request-400 + $caught.Exception.Data['RequestUri'] | Should -BeExactly 'https://graph.test/beta/groups' + Should -Invoke Invoke-MgGraphRequest -Exactly 1 + Should -Invoke Start-Sleep -Exactly 0 + } + It 'retries typed connection failures without guessing status codes from unrelated numbers' { Mock Invoke-MgGraphRequest { $script:requestCount++ @@ -153,11 +213,10 @@ Describe 'Invoke-IACGraphRequest' { It 'does not retry a status-bearing HttpRequestException for a permanent 4xx' { Mock Invoke-MgGraphRequest { - throw [System.Net.Http.HttpRequestException]::new( - 'Response status code does not indicate success: 403 (Forbidden).', - $null, - [System.Net.HttpStatusCode]::Forbidden - ) + $exception = [System.Net.Http.HttpRequestException]::new('Forbidden without a status code in the message.') + $exception | Add-Member -NotePropertyName StatusCode ` + -NotePropertyValue ([System.Net.HttpStatusCode]::Forbidden) -Force + throw $exception } { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 3 } | Should -Throw @@ -167,10 +226,18 @@ Describe 'Invoke-IACGraphRequest' { } It 'throws after the configured transient retry count is exhausted' { - Mock Invoke-MgGraphRequest { throw 'HTTP 503 Service Unavailable' } + $serviceUnavailable = $transportFixture.errors.serviceUnavailable + Mock Invoke-MgGraphRequest { + throw (New-GraphFixtureErrorRecord -Fixture $serviceUnavailable -Uri $Uri) + } - { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 2 } | Should -Throw + $caught = $null + try { Invoke-IACGraphRequest -Uri '/groups' -MaxRetryCount 2 } + catch { $caught = $_ } + $caught.Exception.Data['StatusCode'] | Should -Be 503 + $caught.Exception.Data['GraphErrorCode'] | Should -BeExactly ServiceUnavailable + $caught.Exception.Data['ClientRequestId'] | Should -BeExactly client-503 Should -Invoke Invoke-MgGraphRequest -Exactly 3 Should -Invoke Start-Sleep -Exactly 2 } @@ -209,15 +276,9 @@ Describe 'Invoke-IACGraphRequest' { } It 'preserves structured Graph error details in a terminating error' { + $forbidden = $transportFixture.errors.forbidden Mock Invoke-MgGraphRequest { - $record = [System.Management.Automation.ErrorRecord]::new( - [System.Exception]::new('HTTP 403 Forbidden'), - 'GraphFailure', - [System.Management.Automation.ErrorCategory]::PermissionDenied, - $Uri - ) - $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('{"error":{"code":"Authorization_RequestDenied","message":"Denied","innerError":{"request-id":"request-1","client-request-id":"client-1"}}}') - throw $record + throw (New-GraphFixtureErrorRecord -Fixture $forbidden -Uri $Uri) } $caught = $null @@ -227,8 +288,8 @@ Describe 'Invoke-IACGraphRequest' { $caught.FullyQualifiedErrorId | Should -Match '^IntuneAssignmentChecker.GraphRequestFailed' $caught.Exception.Data['StatusCode'] | Should -Be 403 $caught.Exception.Data['GraphErrorCode'] | Should -BeExactly 'Authorization_RequestDenied' - $caught.Exception.Data['RequestId'] | Should -BeExactly 'request-1' - $caught.Exception.Data['ClientRequestId'] | Should -BeExactly 'client-1' + $caught.Exception.Data['RequestId'] | Should -BeExactly 'request-403' + $caught.Exception.Data['ClientRequestId'] | Should -BeExactly 'client-403' } It 'rejects absolute URLs outside the active cloud endpoint' { From 83c9f9a3c9bcfcc5e08a56ee10324e9af21c51d9 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:54:53 +0200 Subject: [PATCH 8/8] Fix snapshot version lookup on clean runners --- .../Private/AssignmentSnapshot.ps1 | 6 +++++- Tests/Unit/AssignmentSnapshot.Tests.ps1 | 21 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 index 207c968..cbaba5c 100644 --- a/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 +++ b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 @@ -204,7 +204,11 @@ function Get-IACInstalledModuleVersion { if ($loadedModule -and $loadedModule.Version) { return $loadedModule.Version.ToString() } $manifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'IntuneAssignmentChecker.psd1' - return (Test-ModuleManifest -Path $manifestPath -ErrorAction Stop).Version.ToString() + $manifestData = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace("$($manifestData.ModuleVersion)")) { + throw "Module manifest '$manifestPath' does not declare ModuleVersion." + } + return "$($manifestData.ModuleVersion)" } function New-IACAssignmentSnapshot { diff --git a/Tests/Unit/AssignmentSnapshot.Tests.ps1 b/Tests/Unit/AssignmentSnapshot.Tests.ps1 index 33e3d24..c3a6675 100644 --- a/Tests/Unit/AssignmentSnapshot.Tests.ps1 +++ b/Tests/Unit/AssignmentSnapshot.Tests.ps1 @@ -75,6 +75,21 @@ Describe 'Export-IntuneAssignmentSnapshot' { @($loaded.Records[0].ScopeTags).Count | Should -Be 0 } + It 'reads the installed version without validating external module dependencies' { + Mock Get-Module { $null } + Mock Import-PowerShellDataFile { @{ ModuleVersion = '9.8.7' } } -ParameterFilter { + $LiteralPath -like '*IntuneAssignmentChecker.psd1' + } + Mock Test-ModuleManifest { throw 'Required module is unavailable' } + + Get-IACInstalledModuleVersion | Should -BeExactly '9.8.7' + + Should -Invoke Import-PowerShellDataFile -Exactly 1 -ParameterFilter { + $LiteralPath -like '*IntuneAssignmentChecker.psd1' + } + Should -Invoke Test-ModuleManifest -Exactly 0 + } + It 'writes byte-identical JSON for the same records and capture metadata regardless of input order' { $firstPath = Join-Path $TestDrive 'first.json' $secondPath = Join-Path $TestDrive 'second.json' @@ -481,8 +496,8 @@ Describe 'Compare-IntuneAssignmentSnapshot' { Describe 'Assignment snapshot public surface' { It 'exports both snapshot commands from the module manifest' { - $manifest = Test-ModuleManifest (Join-Path $moduleRoot 'IntuneAssignmentChecker.psd1') - $manifest.ExportedFunctions.Keys | Should -Contain Export-IntuneAssignmentSnapshot - $manifest.ExportedFunctions.Keys | Should -Contain Compare-IntuneAssignmentSnapshot + $manifestData = Import-PowerShellDataFile -LiteralPath (Join-Path $moduleRoot 'IntuneAssignmentChecker.psd1') + $manifestData.FunctionsToExport | Should -Contain Export-IntuneAssignmentSnapshot + $manifestData.FunctionsToExport | Should -Contain Compare-IntuneAssignmentSnapshot } }