IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
+524
View File
@@ -0,0 +1,524 @@
function Set-GraphBulkAssignments
{
<#
.SYNOPSIS
Bulk-update Intune policy assignments.
.DESCRIPTION
Public, UI-independent driver for the Bulk Assignments tool. Lists
policies for the requested type(s) / group(s), applies the chosen
Action (Add / Replace / Remove), and POSTs the new assignments list
to the policy's /assign endpoint (the canonical "replace all
assignments" operation Intune uses internally).
Supported shapes:
* Simple — `{target}` only. Default for most types
(DeviceConfiguration, Compliance, EndpointSecurity, Scripts,
EnrollmentConfiguration, Autopilot, PolicySet, etc.).
* App — `{target, intent}`. Used by types whose AssignmentsType
is `mobileAppAssignments`. Per-platform `settings` is NOT
populated yet (Phase 3); Graph defaults are accepted.
Targets supported in this phase:
* Group, exclusion-group, all-devices, all-users
* Optional assignment filter (include/exclude) on group targets
Health-script assignments (`runSchedule` + `runRemediationScript`)
are filtered out — they need a per-type schedule editor that lands
in a later phase.
The /assign endpoint REPLACES all assignments for the policy, so
Add and Remove modes GET each policy's current assignments first
(via Get-GraphPolicies -IncludeAssignments), compute the new list,
and POST the merged result. Replace mode skips the merge step.
.PARAMETER AssignmentSettings
An [IntuneManagerAssignmentSettings] instance. The UI binds to one;
callers can construct one directly.
.PARAMETER Action
Overrides AssignmentSettings.Action when supplied.
.PARAMETER Assignments
Overrides AssignmentSettings.Assignments when supplied.
.PARAMETER Filter
Name filter (literal substring, case-insensitive) matched against each
policy's Name. Empty = no filter. Overrides AssignmentSettings.Filter
when supplied.
.PARAMETER PolicyType
Restrict the run to these PolicyType IDs.
.PARAMETER PolicyGroup
Restrict the run to these PolicyGroup IDs (expanded to their member types).
.PARAMETER TokenId
Authentication token id. Defaults to the current default token.
.EXAMPLE
$s = [IntuneManagerAssignmentSettings]::new()
$s.Action = 'Add'
$s.Assignments = @([PSCustomObject]@{
TargetType = 'groupAssignmentTarget'
GroupId = '<aad-group-id>'
GroupName = 'All Helpdesk Devices'
})
Set-GraphBulkAssignments -AssignmentSettings $s -PolicyGroup DeviceConfiguration
.OUTPUTS
PSCustomObject @{
Types, PoliciesScanned, PoliciesMatched, PoliciesUpdated,
PoliciesSkipped, PoliciesFailed, PoliciesUnsupported, Duration
}
#>
[CmdletBinding()]
param(
[IntuneManagerAssignmentSettings]
$AssignmentSettings,
[ValidateSet("Add","Replace","Remove")]
[string]
$Action,
[PSCustomObject[]]
$Assignments,
[string]
$Filter,
[string[]]
$PolicyType,
[string[]]
$PolicyGroup,
[Int]
$TokenId = (Get-DefaultTokenId)
)
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
if(-not $AssignmentSettings) { $AssignmentSettings = [IntuneManagerAssignmentSettings]::new() }
if($PSBoundParameters.ContainsKey('Action')) { $AssignmentSettings.Action = $Action }
if($PSBoundParameters.ContainsKey('Assignments')) { $AssignmentSettings.Assignments = @($Assignments) }
if($PSBoundParameters.ContainsKey('Filter')) { $AssignmentSettings.Filter = $Filter }
if($AssignmentSettings.Action -notin @("Add","Replace","Remove")) {
throw "Invalid Action '$($AssignmentSettings.Action)'. Expected Add, Replace, or Remove."
}
$chosen = @($AssignmentSettings.Assignments | Where-Object { $_ })
if($chosen.Count -eq 0) {
throw "No assignments selected. Specify at least one target in AssignmentSettings.Assignments."
}
# Phase 1 supports only the simple target-only shape. Anything else (apps
# with intent + settings, health scripts with runSchedule) would silently
# drop user fields during the Graph round-trip, so reject up-front.
$simpleTargetTypes = @($chosen | Where-Object {
$_.TargetType -in @(
"groupAssignmentTarget",
"exclusionGroupAssignmentTarget",
"allDevicesAssignmentTarget",
"allLicensedUsersAssignmentTarget")
})
if($simpleTargetTypes.Count -ne $chosen.Count) {
$unknown = @($chosen | Where-Object { $_ -notin $simpleTargetTypes } | ForEach-Object { $_.TargetType }) -join ', '
throw "Unsupported assignment target type(s): $unknown. Phase 1 supports groupAssignmentTarget, exclusionGroupAssignmentTarget, allDevicesAssignmentTarget, allLicensedUsersAssignmentTarget."
}
foreach($a in $chosen) {
if($a.TargetType -in @("groupAssignmentTarget","exclusionGroupAssignmentTarget") -and -not $a.GroupId) {
throw "Assignment of type '$($a.TargetType)' is missing GroupId."
}
}
# installIntent enum values accepted by Graph for mobileAppAssignment.
# Non-app shapes ignore Intent — see Build-AssignmentBody below.
$validIntents = @("available","notAvailable","required","uninstall","availableWithoutEnrollment")
foreach($a in $chosen) {
if($a.Intent -and $a.Intent -notin $validIntents) {
throw "Invalid Intent '$($a.Intent)' for target '$($a.GroupName)'. Expected one of: $($validIntents -join ', ')."
}
}
# ---- 1. Resolve target policy types ----
# Filter to types that support the bulk assignment action shape. Some
# Intune objects expose an `assignments` navigation property for export or
# documentation, but do not support the replace-all /assign action used by
# this tool.
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Set-GraphBulkAssignments'
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
$targetTypes = [System.Collections.Generic.List[object]]::new()
$targetTypes.AddRange([object[]]$selection.Types)
$unsupportedSelected = [System.Collections.Generic.List[object]]::new()
if(-not $PolicyType -and -not $PolicyGroup) {
foreach($grp in $script:IntuneGroups) {
if(-not $grp.Title) { continue }
foreach($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
}
}
$targetTypes = @($targetTypes | Sort-Object -Property Id -Unique)
$eligibleTypes = [System.Collections.Generic.List[object]]::new()
foreach($pt in $targetTypes) {
if(-not $pt.SupportsAssignments) { continue }
if(-not $pt.AssignmentsType) { continue }
if(-not (Test-BulkAssignmentSupported $pt)) {
[void]$unsupportedSelected.Add($pt)
continue
}
[void]$eligibleTypes.Add($pt)
}
if($eligibleTypes.Count -eq 0) {
Write-Log "Set-GraphBulkAssignments: no eligible policy types selected (need SupportsAssignments=true and default target-only shape)" 2
return [PSCustomObject]@{
Types = 0
PoliciesScanned = 0
PoliciesMatched = 0
PoliciesUpdated = 0
PoliciesSkipped = 0
PoliciesFailed = 0
PoliciesUnsupported = 0
UnsupportedTypes = @($unsupportedSelected | ForEach-Object { $_.Title })
UnknownSelectors = $unknownSelectors
Duration = $stopwatch.Elapsed
}
}
# ---- 2. Pre-compile name filter ----
# A literal substring, like 3.x and every other bulk driver. This command
# writes assignments, so '[Test]' must mean the text [Test] and never a
# character class that matches most of the tenant.
$filterRegex = $null
if($AssignmentSettings.Filter) {
$filterRegex = [Regex]::new([Regex]::Escape($AssignmentSettings.Filter), 'IgnoreCase')
}
Write-Log "Set-GraphBulkAssignments: action=$($AssignmentSettings.Action), assignments=$($chosen.Count), eligible=$($eligibleTypes.Count), unsupported=$($unsupportedSelected.Count)"
# Surface PolicyTypes whose assignment @odata.type the bulk tool can't
# resolve. These will still PATCH (Graph sometimes accepts an entry
# without an explicit @odata.type, sometimes 400s) but the user should
# know — once per type per run, not once per policy. The fix is to set
# _AssignmentObjectType on the PolicyType class (see IntunePolicyBase).
foreach($pt in $eligibleTypes) {
if(-not (Get-BulkAssignmentObjectType $pt)) {
Write-Log "Set-GraphBulkAssignments: no assignment @odata.type resolved for PolicyType '$($pt.Id)' ($($pt.Title)). Set _AssignmentObjectType on the class or extend Get-BulkAssignmentObjectType." 2
}
}
# ---- 3. List policies (with assignments) for every eligible type ----
# Two-line status: action context pinned on the primary line for the whole
# run; the detail line rotates between listing and the PATCH phase.
Write-Status `
-Text ("Bulk assignments - {0}" -f $AssignmentSettings.Action) `
-Detail ("Listing policies for {0} policy type(s)" -f $eligibleTypes.Count)
$allPolicies = @()
try {
$allPolicies = @(Get-GraphPolicies -PolicyType @($eligibleTypes | ForEach-Object { $_.Id }) -TokenId $TokenId -IncludeAssignments -ErrorAction Stop)
}
catch {
Write-LogError "Set-GraphBulkAssignments: failed to list policies" $_.Exception
}
# The cached policy list can carry STALE assignments from an earlier pass
# in the same session (Add-GraphPolicyAssignments skips policies whose
# assignments are already populated). The Add/Remove merge must see the
# LIVE state - with stale data a Remove computes "nothing changed" and
# silently no-ops. Force a refresh for every policy this run will touch.
$refresh = @($allPolicies | Where-Object {
$_ -and $_.PolicyType -and $_.Object -and
(Test-BulkAssignmentSupported $_.PolicyType) -and
(-not $filterRegex -or $filterRegex.IsMatch([string]$_.Name))
})
foreach($p in $refresh) { Remove-Property $p.Object 'assignments' }
if($refresh.Count -gt 0) {
Add-GraphPolicyAssignments -Policies $refresh -TokenId $TokenId
}
# ---- 4. Build the per-policy POST batch ----
# Tuples carry both Target (final POST shape) and Intent (only set for
# app-shape types). Tuple → assignment-object conversion happens once
# right before serialisation so the shape branch lives in one place.
$scanned = 0
$matched = 0
$skipped = 0
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
$byBatchId = @{}
foreach($p in $allPolicies)
{
$scanned++
if(-not $p -or -not $p.PolicyType) { continue }
if(-not (Test-BulkAssignmentSupported $p.PolicyType)) { continue }
if($filterRegex -and -not $filterRegex.IsMatch([string]$p.Name)) { continue }
$matched++
$shape = Get-BulkAssignmentShape $p.PolicyType
# For app shape: figure out which per-platform settings entry the
# user's chosen list applies to THIS app (source) and which Graph
# @odata.type to stamp on the resulting `settings` object (target).
# Inheritance shortcut: win32CatalogApp reuses Win32 LOB settings
# since the inheriting type has no extra fields of its own.
$sourceSettingsType = $null
$targetSettingsType = $null
if($shape -eq "app" -and $p.Object -and $p.Object.'@odata.type') {
$targetSettingsType = Get-AppSettingsTypeForPolicy $p.Object.'@odata.type'
$sourceSettingsType = if($targetSettingsType -eq 'win32CatalogAppAssignmentSettings') {
'win32LobAppAssignmentSettings'
} else {
$targetSettingsType
}
}
# Project current assignments to (Target, Intent, Settings) tuples.
# Settings on current assignments are already in Graph form (have
# @odata.type), so pass through verbatim — they're only re-emitted
# when the assignment survives Add/Remove.
$current = @()
if($p.Object -and $p.Object.PSObject.Properties['assignments']) {
$current = @($p.Object.assignments | Where-Object { $_ -and $_.target })
}
$currentTuples = @()
$currentKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach($a in $current) {
# Script shape: preserve runSchedule (PSCustomObject from Graph
# with @odata.type already set) and runRemediationScript
# ([Nullable[bool]]) so Add/Remove keeps them on surviving rows.
$runSchedule = $null
$runRemediation = $null
if($shape -eq "script") {
if($a.PSObject.Properties['runSchedule'] -and $a.runSchedule) { $runSchedule = $a.runSchedule }
if($a.PSObject.Properties['runRemediationScript']) { $runRemediation = [Nullable[bool]]$a.runRemediationScript }
}
$tuple = [PSCustomObject]@{
Target = ConvertTo-AssignmentTarget $a.target
Intent = if($shape -eq "app") { [string]$a.intent } else { $null }
Settings = if($shape -eq "app" -and $a.settings) { $a.settings } else { $null }
RunSchedule = $runSchedule
RunRemediation = $runRemediation
}
$currentTuples += $tuple
[void]$currentKeys.Add((Get-AssignmentSignature $tuple.Target $tuple.Intent))
}
# Build chosen tuples for THIS shape. Intent only on apps; Settings
# only when (a) shape is app AND (b) the chosen descriptor has a
# per-platform hashtable matching this app's settings type. Same
# chosen list flows across mixed-type runs unchanged.
$chosenTuples = @()
$chosenKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach($c in $chosen) {
$settings = $null
$runSchedule = $null
$runRemediation = $null
if($shape -eq "app" -and $sourceSettingsType -and $c.Settings -is [Hashtable] -and $c.Settings.ContainsKey($sourceSettingsType)) {
$h = $c.Settings[$sourceSettingsType]
if($h -is [Hashtable] -and $h.Count -gt 0) {
$settings = ConvertTo-AppSettingsObject -Hash $h -GraphType $targetSettingsType
}
}
if($shape -eq "script" -and $c.Settings -is [Hashtable] -and $c.Settings.ContainsKey('deviceHealthScriptAssignment')) {
$h = $c.Settings['deviceHealthScriptAssignment']
if($h -is [Hashtable]) {
if($h.ContainsKey('runRemediationScript')) { $runRemediation = [Nullable[bool]]$h['runRemediationScript'] }
if($h.ContainsKey('scheduleType')) { $runSchedule = Build-HealthScriptSchedule -Spec $h }
}
}
# Some app types cannot take an assignment filter - Graph answers
# "The Assignment Filters are not supported for this app type" and the
# whole policy failed. The portal simply does not offer a filter for
# them, so assign the group as asked and drop the filter, with a log
# line naming the policy. Live-verified for webApp (2026-09-13).
$descriptorForTarget = $c
if($shape -eq "app" -and $c.FilterId -and $p.Object -and
([string]$p.Object.'@odata.type') -in $script:BulkAssignmentAppTypesWithoutFilters) {
Write-Log "Set-GraphBulkAssignments: '$($p.Name)' is a $($p.Object.'@odata.type' -replace '^#microsoft\.graph\.','') - assignment filters are not supported for this app type, assigning without the filter" 2
$descriptorForTarget = [PSCustomObject]@{
TargetType = $c.TargetType
GroupId = $c.GroupId
GroupName = $c.GroupName
}
}
$tuple = [PSCustomObject]@{
Target = ConvertTo-AssignmentTarget (Build-AssignmentTarget $descriptorForTarget)
Intent = if($shape -eq "app") { if($c.Intent) { [string]$c.Intent } else { "required" } } else { $null }
Settings = $settings
RunSchedule = $runSchedule
RunRemediation = $runRemediation
}
$chosenTuples += $tuple
[void]$chosenKeys.Add((Get-AssignmentSignature $tuple.Target $tuple.Intent))
}
# Compute new tuple set per Action.
$newTuples = @()
switch ($AssignmentSettings.Action) {
"Replace" {
$newTuples = $chosenTuples
}
"Add" {
$seen = [System.Collections.Generic.HashSet[string]]::new()
foreach($t in $currentTuples) {
if($seen.Add((Get-AssignmentSignature $t.Target $t.Intent))) { $newTuples += $t }
}
foreach($t in $chosenTuples) {
if($seen.Add((Get-AssignmentSignature $t.Target $t.Intent))) { $newTuples += $t }
}
}
"Remove" {
foreach($t in $currentTuples) {
$sig = Get-AssignmentSignature $t.Target $t.Intent
if($chosenKeys.Contains($sig)) { continue }
$newTuples += $t
}
}
}
# No-op if the resulting assignments are identical to current
# (order-independent). Use the full tuple signature here, not just
# target+intent, so Replace can update app settings and health-script
# schedule/remediation fields for an existing target.
$currentFullKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach($t in $currentTuples) { [void]$currentFullKeys.Add((Get-AssignmentFullSignature $t)) }
$newFullKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach($t in $newTuples) { [void]$newFullKeys.Add((Get-AssignmentFullSignature $t)) }
if($newFullKeys.Count -eq $currentFullKeys.Count -and
(@($newFullKeys | Where-Object { -not $currentFullKeys.Contains($_) }).Count -eq 0)) {
$skipped++
# A Remove that finds nothing to remove is the case worth explaining:
# either the target was never there, or the refresh read came back
# empty and the assignment is about to be left behind.
if($AssignmentSettings.Action -eq 'Remove') {
Write-Log "Set-GraphBulkAssignments: '$($p.Name)' [$($p.PolicyType.Id)] - nothing to remove (current=$($currentTuples.Count), chosen=$($chosenTuples.Count))"
}
continue
}
# Tuples → POST objects (shape-aware).
# app → {target, intent, settings?}
# script → {target, runRemediationScript?, runSchedule?}
# simple → {target}
# All optional fields are omitted entirely when null/absent so Graph
# keeps its defaults rather than seeing $null / empty objects.
$assignmentObjectType = Get-BulkAssignmentObjectType $p.PolicyType
$newAssignments = foreach($t in $newTuples) {
if($shape -eq "app") {
$obj = [ordered]@{}
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
$obj.target = $t.Target
$obj.intent = (?? $t.Intent "required")
if($t.Settings) { $obj.settings = $t.Settings }
[PSCustomObject]$obj
}
elseif($shape -eq "script") {
$obj = [ordered]@{}
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
$obj.target = $t.Target
if($null -ne $t.RunRemediation) { $obj.runRemediationScript = [bool]$t.RunRemediation }
if($t.RunSchedule) { $obj.runSchedule = $t.RunSchedule }
[PSCustomObject]$obj
}
else {
$obj = [ordered]@{}
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
$obj.target = $t.Target
[PSCustomObject]$obj
}
}
$body = [PSCustomObject]@{
$p.PolicyType.AssignmentsType = @($newAssignments)
}
$bodyJson = $body | ConvertTo-Json -Depth 20 -Compress
$batchId = [string]$batch.Count
[void]$batch.Add([PSCustomObject]@{
id = $batchId
method = "POST"
# AssignAction is "assign" for almost every type; policySets have
# no /assign segment and take the replacement list via /update.
url = "$($p.PolicyType.API)/$($p.Id)/$($p.PolicyType.AssignAction)"
body = ($bodyJson | ConvertFrom-Json)
headers = @{ "Content-Type" = "application/json" }
})
$byBatchId[$batchId] = [PSCustomObject]@{ Policy = $p; NewAssignments = @($newAssignments) }
}
# ---- 6. Dispatch the batch ----
$updated = 0
$failed = 0
if($batch.Count -gt 0) {
Write-Status -Detail ("Updating assignments on {0} policy(ies)" -f $batch.Count) -SkipLog
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "BulkAssignments" -TokenId $TokenId -IncludedFailed -SkipWarnings)
if($results.Count -eq 0) {
$failed += $batch.Count
Write-Log "Set-GraphBulkAssignments: batch returned no responses; treating all $($batch.Count) request(s) as failed" 3
}
else {
foreach($r in $results) {
$entry = $byBatchId["$($r.Id)"]
if(-not $entry) { continue }
$statusCode = 0
try { $statusCode = [int]$r.status } catch { }
if($statusCode -ge 200 -and $statusCode -lt 300) {
$updated++
# Mirror so a subsequent UI refresh sees the new list
# without a fresh round-trip.
try {
if($entry.Policy.Object.PSObject.Properties['assignments']) {
$entry.Policy.Object.assignments = $entry.NewAssignments
}
else {
$entry.Policy.Object | Add-Member -MemberType NoteProperty -Name 'assignments' -Value $entry.NewAssignments -Force
}
}
catch { Write-LogDebug "Set-GraphBulkAssignments: in-memory mirror failed for $($entry.Policy.Name): $($_.Exception.Message)" }
}
else {
$failed++
$msg = ""
if($r.body -and $r.body.error -and $r.body.error.message) { $msg = $r.body.error.message }
Write-Log "Set-GraphBulkAssignments: /assign failed for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)] - $statusCode $msg" 3
}
}
# Pending entries with no response → treat as failed (matches
# the codex fix pattern from Set-GraphBulkScopeTags).
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
foreach($r in $results) { [void]$seenIds.Add([string]$r.Id) }
foreach($pending in $byBatchId.Keys) {
if(-not $seenIds.Contains([string]$pending)) {
$failed++
$entry = $byBatchId[$pending]
Write-Log "Set-GraphBulkAssignments: no batch response for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)]" 3
}
}
}
}
Write-Status ""
[PSCustomObject]@{
Types = $eligibleTypes.Count
PoliciesScanned = $scanned
PoliciesMatched = $matched
PoliciesUpdated = $updated
PoliciesSkipped = $skipped
PoliciesFailed = $failed
PoliciesUnsupported = 0 # placeholder — unsupported TYPES are reported separately below
UnsupportedTypes = @($unsupportedSelected | ForEach-Object { $_.Title })
UnknownSelectors = $unknownSelectors
Duration = $stopwatch.Elapsed
}
}