Private/ConvertTo-MsecAssignmentTarget.ps1
|
function ConvertTo-MsecAssignmentTarget { <# .SYNOPSIS Turns an Intune policy's expanded assignments into filterable target columns plus one structured row per assignment. .DESCRIPTION Answers "who is this actually aimed at" - All Users, All Devices, a group, or some combination - from the assignments already expanded onto the policy. No Graph call of its own: the target TYPE is carried in each assignment's target.'@odata.type', so the whole question is answerable from data the caller already has. Only group display NAMES need a lookup, and those are passed in. SEPARATE, TYPED COLUMNS rather than one summary line. The kind of target and the group it points at are different facts, and a caller wants to filter on them independently - `Where-Object AssignmentType -contains 'AllUsers'` is an exact test, where matching a rendered phrase would be string-matching a display value. This module has been bitten by that often enough (see the Global Administrator / 'Company Administrator' naming trap) that no column here is a sentence. EXCLUSIONS ARE TARGETS TOO, and this is why a count was never enough. Intune models "everyone except the pilot ring" as an allLicensedUsers target plus an exclusionGroup target - two assignments. A policy reported as "2 assignments" is then indistinguishable from one aimed at two unrelated groups. Excluded groups get their own TargetType AND their own column, kept apart from included ones: two parallel name lists would leave the reader guessing which name was the carve-out. ASSIGNMENT FILTERS narrow a target further, on device properties evaluated at check-in, and are likewise invisible in a count. An 'All Devices' assignment carrying an include filter is not all devices. HasFilter flags any such assignment; FilterType and FilterId on the detail rows carry the specifics. Filter names are not resolved - a separate endpoint and permission - and the presence of a filter is what changes how the row reads. .PARAMETER Assignment The policy's expanded assignments collection. $null or empty is fine, and yields empty columns rather than $null ones. .PARAMETER GroupName Optional hashtable of groupId -> display name. Ids with no entry keep their id as the name, so a target is never nameless. .OUTPUTS PSCustomObject with: Type - string[], distinct target types in a fixed order Group - string[], INCLUDED group names (or ids) ExcludedGroup - string[], EXCLUDED group names (or ids) HasFilter - bool, any assignment narrowed by an assignment filter Detail - one PSCustomObject per assignment: TargetType, IsExclusion, GroupId, GroupName, FilterType, FilterId, Raw Every collection is an array, empty rather than $null, so -contains and .Count work on an unassigned policy without a null check at each call site. .NOTES TargetType values, mapped from target.'@odata.type': allLicensedUsersAssignmentTarget -> AllUsers allDevicesAssignmentTarget -> AllDevices groupAssignmentTarget -> Group exclusionGroupAssignmentTarget -> ExclusionGroup configurationManagerCollectionAssignmentTarget -> ConfigManagerCollection A target carrying an '@odata.type' msec does not recognise keeps that type with the '#microsoft.graph.' prefix stripped, rather than being flattened to 'Unknown': Intune has added target types before, and a name you can look up beats a label that hides one. 'Unknown' is used only when there is no '@odata.type' at all - where it is the accurate description rather than a shrug. Type is DISTINCT, so a policy aimed at three groups reports 'Group' once; the multiplicity lives in Group.Count and AssignmentCount. Its order is fixed - All Users, All Devices, groups, exclusions, collections, then anything else - so the same set of assignments always projects the same way and two policies can be compared without sorting first. #> [CmdletBinding()] param( [Parameter(Position = 0)] [AllowNull()] $Assignment, [Parameter()] [hashtable] $GroupName ) $detail = [System.Collections.Generic.List[object]]::new() foreach ($a in @($Assignment)) { if (-not $a) { continue } $target = $a.target $odata = if ($target) { [string]$target.'@odata.type' } else { '' } $short = $odata -replace '^#?microsoft\.graph\.', '' $type = switch ($short) { 'allLicensedUsersAssignmentTarget' { 'AllUsers' } 'allDevicesAssignmentTarget' { 'AllDevices' } 'groupAssignmentTarget' { 'Group' } 'exclusionGroupAssignmentTarget' { 'ExclusionGroup' } 'configurationManagerCollectionAssignmentTarget' { 'ConfigManagerCollection' } default { if ($short) { $short } else { 'Unknown' } } } $groupId = if ($target) { [string]$target.groupId } else { $null } # 'none' and $null both mean unfiltered; normalised so callers test one thing. $filterType = if ($target) { [string]$target.deviceAndAppManagementAssignmentFilterType } else { $null } if (-not $filterType -or $filterType -eq 'none') { $filterType = $null } $detail.Add([PSCustomObject]@{ TargetType = $type IsExclusion = ($type -eq 'ExclusionGroup') GroupId = if ($groupId) { $groupId } else { $null } GroupName = if (-not $groupId) { $null } elseif ($GroupName -and $GroupName.ContainsKey($groupId)) { $GroupName[$groupId] } else { $groupId } FilterType = $filterType FilterId = if ($target -and $target.deviceAndAppManagementAssignmentFilterId) { [string]$target.deviceAndAppManagementAssignmentFilterId } else { $null } Raw = $a }) } # Fixed order, so the projection is stable and two policies are comparable without # sorting. Anything msec does not know about trails the known kinds, alphabetically. $rank = @{ 'AllUsers' = 0; 'AllDevices' = 1; 'Group' = 2; 'ExclusionGroup' = 3; 'ConfigManagerCollection' = 4 } $types = @( $detail.TargetType | Sort-Object -Unique | Sort-Object @{ Expression = { if ($rank.ContainsKey($_)) { $rank[$_] } else { 99 } } }, @{ Expression = { $_ } } ) [PSCustomObject]@{ Type = $types Group = @($detail | Where-Object TargetType -eq 'Group' | ForEach-Object { $_.GroupName }) ExcludedGroup = @($detail | Where-Object TargetType -eq 'ExclusionGroup' | ForEach-Object { $_.GroupName }) HasFilter = [bool] @($detail | Where-Object FilterType).Count Detail = $detail.ToArray() } } |