Providers/Entra/Public/Update-EntraContainment.ps1
|
function Update-EntraContainment { <# .SYNOPSIS Places any seeded object that is not in its administrative unit .DESCRIPTION Reconciles what the containers hold against what the tenant actually contains, and fixes the difference. This exists because placement is the one part of seeding that can fail without anything else being wrong. The references are added seconds after the objects are created, so they lose races with replication, and a batch that exhausts its retries leaves objects that exist, work, and are simply not in their container. Nothing looks broken until teardown falls back to matching on names. Running it is cheap and idempotent: objects already in their unit are recognised and skipped, so it is safe to run repeatedly and safe to run against an environment that is already correct. It reconciles in one direction only. An object in a unit that this module cannot otherwise account for is reported and left alone, never removed - the unit is a container this module created, but membership of it is not proof that this module should be managing what somebody else put in it. .PARAMETER ObjectType Which classes to reconcile. Defaults to all four that can belong to a unit. .PARAMETER ShowProgress Draws a progress bar .PARAMETER PassThru Returns a per-type summary .OUTPUTS EntraContainmentResult[] when -PassThru is supplied .EXAMPLE PS> Update-EntraContainment DESCRIPTION: Places anything that was created but not contained OUTPUT: None USE CASE: The last step of New-EntraEnvironment, and after any partial seed .EXAMPLE PS> Update-EntraContainment -PassThru | Format-Table DESCRIPTION: Reports how many of each type were already contained and how many were added OUTPUT: One row per object type USE CASE: Confirming teardown will be able to use the strong ownership route .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding(SupportsShouldProcess)] [OutputType('EntraContainmentResult')] param( [Parameter()] [ValidateSet('Users', 'Groups', 'Devices', 'Applications')] [string[]]$ObjectType = @('Users', 'Groups', 'Devices', 'Applications'), [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection # The Graph type name behind each class, needed for the typed member query. $graphType = @{ Users = 'user' Groups = 'group' Devices = 'device' Applications = 'application' } $units = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection) $results = [System.Collections.Generic.List[object]]::new() $index = 0 foreach ($type in $ObjectType) { $index++ Write-TestProgress -Activity 'Reconciling containment' -Status $type ` -PercentComplete ([int](100 * $index / $ObjectType.Count)) -ShowProgress:$ShowProgress $unitName = '{0}{1}' -f $marker.Prefix, $type $unit = $units | Where-Object { $_.displayName -eq $unitName } | Select-Object -First 1 if (-not $unit) { Write-Warning "No administrative unit called '$unitName' exists, so $type cannot be contained." continue } # Everything the module can account for, by either route. $seeded = @(Get-EntraSeededObject -Type $type -Connection $connection) if ($seeded.Count -eq 0) { continue } $contained = @{} try { foreach ($member in (Invoke-EntraRequest -Method GET -Connection $connection -Paginate ` -Path "/directory/administrativeUnits/$($unit.id)/members/microsoft.graph.$($graphType[$type])" ` -Query @{ '$select' = 'id' })) { $contained[$member.id] = $true } } catch { Write-Warning "Could not read the members of '$unitName': $($_.Exception.Message)" continue } $missing = @($seeded | Where-Object { -not $contained.ContainsKey($_.id) }) $added = 0 if ($missing.Count -gt 0) { if ($PSCmdlet.ShouldProcess($unitName, "Place $($missing.Count) $type")) { $added = Add-EntraUnitMember -UnitId $unit.id -ObjectId @($missing.id) -Connection $connection ` -Activity "Placing $type in $unitName" -ShowProgress:$ShowProgress } } Write-Verbose "$type : $($seeded.Count) seeded, $($contained.Count) already contained, $($missing.Count) missing, $added placed" $results.Add([PSCustomObject]@{ PSTypeName = 'EntraContainmentResult' ObjectType = $type AdministrativeUnit = $unitName Seeded = $seeded.Count AlreadyContained = $contained.Count Missing = $missing.Count Placed = $added }) } Write-TestProgress -Activity 'Reconciling containment' -Completed -ShowProgress:$ShowProgress $stillMissing = @($results | Where-Object { $_.Missing -gt $_.Placed }) if ($stillMissing.Count -gt 0) { Write-Warning ("Some objects could not be placed in their administrative unit. They remain identifiable " + "by the name prefix, so teardown will still find them, but by the weaker route. Run this again once " + "the directory has settled.") } if ($PassThru) { return $results.ToArray() } } |