Providers/Entra/Public/Remove-EntraEnvironment.ps1
|
function Remove-EntraEnvironment { <# .SYNOPSIS Removes everything this module created, and refuses to remove anything else .DESCRIPTION Tears the seeded environment down in the reverse of the order it was built, deleting only objects whose ownership has been proven by Get-EntraSeededObject. Ownership is proven, never assumed. An object that merely looks like test data is left alone and reported. That rule exists because this module seeds into a tenant that is in real use: a group called "Department Finance" that somebody created by hand is one prefix collision away from being deleted by a tool that matched on name alone, and a deleted Entra object is recoverable only by somebody who notices inside thirty days. The order is forced by Entra's own dependencies, and three steps in it are not obvious: - Role eligibilities are withdrawn before anything else, because they reference both a role definition and a principal and a live one blocks the definition from being deleted. They are also withdrawn rather than deleted: a schedule has no DELETE, so the removal is a second request posted with action adminRemove. - Licences are removed from the seeded group before the group is deleted. Verified against a live tenant: DELETE on a group holding an active licence fails with "A group with active licenses assigned cannot be deleted". The removal is also asynchronous, so the delete is retried rather than attempted once. - Conditional Access policies and named locations go first, because a policy holding a reference to a location or a group blocks nothing from being deleted but does leave a policy pointing at an object that no longer exists, which is a worse state to leave a live tenant in than either extreme. Deletion is soft. Users, groups and applications go to the directory recycle bin for thirty days, and they behave differently there: a deleted user has its UPN rewritten to free the original for reuse, while a deleted group keeps its displayName and mailNickname reserved. So a tear-down-then-immediately-re-seed collides on groups and not on users unless -PurgeRecycleBin is used. Devices, named locations and Conditional Access policies are not soft-deleted at all. -WhatIf beats -Force. If both are passed, nothing is deleted. .PARAMETER Keep Layers to leave in place. Takes the same names as New-EntraEnvironment's -Skip. .PARAMETER PurgeRecycleBin Permanently removes the soft-deleted objects afterwards, so a re-seed does not collide with a reserved group name. This is irreversible - purged objects cannot be restored. .PARAMETER SkipPermissionCheck Attempt every layer without first judging whether the identity can remove it. By default the token's permissions and the identity's directory roles are read up front, and a layer the identity cannot delete is set aside with one warning rather than prompted for object by object and refused object by object. The judgement errs towards attempting - an identity whose rights cannot be read is not refused - but a tenant with rights granted some way this module does not read can pass this to skip the judgement altogether. .PARAMETER Force Suppresses the confirmation prompt .PARAMETER ShowProgress Draws a progress bar .PARAMETER PassThru Returns what was removed, and what was left alone .OUTPUTS EntraTeardownResult when -PassThru is supplied .EXAMPLE PS> Remove-EntraEnvironment -WhatIf DESCRIPTION: Lists exactly what would be deleted, and deletes nothing OUTPUT: One What if line per object USE CASE: Always worth running first .EXAMPLE PS> Remove-EntraEnvironment -Force -PurgeRecycleBin DESCRIPTION: Removes everything and empties the recycle bin of it OUTPUT: None USE CASE: Tearing down before an immediate re-seed, where a reserved group name would collide .EXAMPLE PS> Remove-EntraEnvironment -Keep Users, Groups -Force DESCRIPTION: Removes the policy, application and device layers but leaves the directory OUTPUT: None USE CASE: Rebuilding the access layer over a directory that is expensive to recreate .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType('EntraTeardownResult')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Keep', Justification = 'Used inside the shouldRun scriptblock, which the analyzer does not follow.')] param( [Parameter()] [ValidateSet('ConditionalAccessPolicies', 'AuthenticationStrengths', 'NamedLocations', 'RoleEligibilities', 'DirectoryRoles', 'Licenses', 'Applications', 'Devices', 'Groups', 'Users', 'AdministrativeUnits')] [string[]]$Keep, [Parameter()] [switch]$PurgeRecycleBin, [Parameter()] [switch]$RemoveServiceApp, [Parameter()] [switch]$SkipPermissionCheck, [Parameter()] [switch]$Force, [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection # -Force must not override -WhatIf. ShouldProcess already handles this, but ConfirmPreference # is what suppresses the prompt, and setting it unconditionally would make -Force silently # win over a -WhatIf passed alongside it. if ($Force -and -not $WhatIfPreference) { $ConfirmPreference = 'None' } $removed = [System.Collections.Generic.List[object]]::new() $skipped = [System.Collections.Generic.List[object]]::new() $shouldRun = { param($Layer) $Keep -notcontains $Layer } $record = { param($Type, $Name, $Id, $Outcome, $Detail) $entry = [PSCustomObject]@{ PSTypeName = 'EntraTeardownEntry' Type = $Type Name = $Name Id = $Id Outcome = $Outcome Detail = $Detail } if ($Outcome -eq 'Removed') { $removed.Add($entry) } else { $skipped.Add($entry) } } # A generic delete used by most layers. Every delete retries a 404, because an object # touched moments earlier in this same teardown may not be addressable on every replica. # ErrorMatch adds a second retry condition for the layers where a dependency is torn down # asynchronously and the delete has to wait for it. $deleteObject = { param($Path, $Type, $Name, $Id, $ErrorMatch) try { $deleteArgs = @{ Method = 'DELETE' Path = $Path RetryOnNotFound = $true Connection = $connection } if ($ErrorMatch) { $deleteArgs.RetryOnErrorMatch = $ErrorMatch } Invoke-EntraRequest @deleteArgs | Out-Null & $record $Type $Name $Id 'Removed' $null Write-Verbose "Deleted $Type '$Name'" } catch { & $record $Type $Name $Id 'Failed' $_.Exception.Message Write-Warning "Could not delete $Type '${Name}': $($_.Exception.Message)" } } # Batched deletion for the high-volume layers. At AD parity teardown removes roughly eleven # hundred objects, and one DELETE each would take longer than the seed that created them. # The per-object ShouldProcess is still asked first, so -WhatIf and -Confirm behave exactly # as they do on the serial path. $deleteMany = { param($UrlPrefix, $Type, $Objects, $NameProperty) $approved = [System.Collections.Generic.List[object]]::new() foreach ($object in $Objects) { $name = $object.$NameProperty if (-not $PSCmdlet.ShouldProcess($name, "Delete $Type")) { continue } $approved.Add([PSCustomObject]@{ Reference = $object.id Method = 'DELETE' Url = "$UrlPrefix/$($object.id)" Name = $name }) } if ($approved.Count -eq 0) { return } $results = @(Invoke-EntraBatch -Request $approved.ToArray() -Connection $connection -RetryOnNotFound ` -Activity "Deleting $Type" -ShowProgress:$ShowProgress) $nameById = @{} foreach ($item in $approved) { $nameById[$item.Reference] = $item.Name } foreach ($result in $results) { $name = $nameById[$result.Reference] if ($result.Success) { & $record $Type $name $result.Reference 'Removed' $null } else { & $record $Type $name $result.Reference 'Failed' $result.Error Write-Warning "Could not delete $Type '${name}': $($result.Error)" } } } # Every step enumerates what it owns before deleting it, and an enumeration can fail for a # reason that has nothing to do with the objects - a delegated token without Policy.Read.All # is refused the authentication strengths with a 403. Left unguarded, that exception # escaped the whole function after the Conditional Access policies were already gone, and # a tenant was left half torn down with nothing reporting what had stopped. A step that # cannot read now records that as a failure of its own, warns, and yields nothing, so the # steps after it still run and the summary says what was not attempted. $enumerate = { param($Type, $Label) try { return @(Get-EntraSeededObject -Type $Type -Connection $connection -ErrorAction Stop) } catch { & $record $Type "(every $Label)" $null 'Failed' "Could not enumerate: $($_.Exception.Message)" Write-Warning "Could not enumerate the $Label, so none were removed: $($_.Exception.Message)" return @() } } # --- What this identity can remove, judged once, before anything is prompted for -------- # A run that asked for confirmation on every service principal and then refused every # one with a 403 is what this replaces. The judgement reads the token's permissions and # the identity's directory roles; a layer it cannot delete is recorded and skipped with # one warning, and never reaches ShouldProcess. An identity whose rights cannot be read # at all is allowed everything, because refusing on no evidence is the worse failure. $capability = $null if (-not $SkipPermissionCheck) { try { $capability = Get-EntraTeardownCapability -Connection $connection } catch { Write-Verbose "Could not judge the identity's rights, so every layer is attempted: $($_.Exception.Message)" } } $permitted = { param($Layer) if (-not $capability) { return $true } $verdict = $capability.Layers[$Layer] if (-not $verdict -or $verdict.Allowed) { return $true } & $record $Layer "(every $Layer)" $null 'Skipped' "Not attempted: $($verdict.Reason)." Write-Warning ("Skipping $Layer without prompting: $($verdict.Reason). " + 'Pass -SkipPermissionCheck to attempt them anyway.') return $false } Write-Verbose "Tearing down objects under prefix '$($marker.Prefix)' in tenant $($connection.TenantName)" # --- 1. Conditional Access policies ------------------------------------------------- if ((& $shouldRun 'ConditionalAccessPolicies') -and (& $permitted 'ConditionalAccessPolicies')) { Write-TestProgress -Activity 'Removing environment' -Status 'Conditional Access policies' -PercentComplete 5 -ShowProgress:$ShowProgress foreach ($policy in (& $enumerate 'ConditionalAccessPolicies' 'Conditional Access policies')) { if (-not $PSCmdlet.ShouldProcess($policy.displayName, 'Delete Conditional Access policy')) { continue } & $deleteObject "/identity/conditionalAccess/policies/$($policy.id)" 'ConditionalAccessPolicy' $policy.displayName $policy.id } } # --- 1b. Authentication strengths ---------------------------------------------------- # After the policies, because a strength still referenced by one cannot be deleted, and # for the same reason as the named locations below the reference is dropped asynchronously. if ((& $shouldRun 'AuthenticationStrengths') -and (& $permitted 'AuthenticationStrengths')) { Write-TestProgress -Activity 'Removing environment' -Status 'Authentication strengths' -PercentComplete 10 -ShowProgress:$ShowProgress foreach ($strength in (& $enumerate 'AuthenticationStrengths' 'authentication strengths')) { if (-not $PSCmdlet.ShouldProcess($strength.displayName, 'Delete authentication strength')) { continue } & $deleteObject "/identity/conditionalAccess/authenticationStrength/policies/$($strength.id)" ` 'AuthenticationStrength' $strength.displayName $strength.id 'referenced by|in use' } } # --- 1c. Role eligibilities ---------------------------------------------------------- # Before the role definitions below, and that ordering is forced: a definition with a live # eligibility pointing at it cannot be deleted. Before the users and groups as well, because # deleting a principal leaves its eligibility behind pointing at an object that no longer # resolves, which is a worse state to leave a live tenant in than either extreme. # # An eligibility is not deleted, it is withdrawn. There is no DELETE on a schedule - the # removal is itself a request, posted with action adminRemove against the same collection # that created it, so this cannot use the generic delete helper. # Set when the eligibilities could not be enumerated, which forces the role definitions to be # kept below. Deleting a definition is what makes an eligibility unfindable forever, so # "could not tell" has to be treated differently from "there are none". $eligibilityReadFailed = $false if ((& $shouldRun 'RoleEligibilities') -and (& $permitted 'RoleEligibilities')) { Write-TestProgress -Activity 'Removing environment' -Status 'Role eligibilities' -PercentComplete 11 -ShowProgress:$ShowProgress $eligibilities = @() try { $eligibilities = @(Get-EntraSeededObject -Type RoleEligibilities -Connection $connection -ErrorAction Stop) } catch { $eligibilityReadFailed = $true Write-Warning ("Could not enumerate the role eligibilities: $($_.Exception.Message). Keeping the " + 'custom role definitions, because deleting them would leave any eligibility that does exist ' + 'pointing at a role that no longer resolves, with nothing left to identify it by.') } foreach ($eligibility in $eligibilities) { if (-not $PSCmdlet.ShouldProcess($eligibility.displayName, 'Remove eligible role schedule')) { continue } try { Invoke-EntraRequest -Method POST -Connection $connection -RetryOnNotFound ` -Path '/roleManagement/directory/roleEligibilityScheduleRequests' -Body @{ action = 'adminRemove' principalId = $eligibility.principalId roleDefinitionId = $eligibility.roleDefinitionId directoryScopeId = $eligibility.directoryScopeId justification = $marker.Description } | Out-Null & $record 'RoleEligibility' $eligibility.displayName $eligibility.id 'Removed' $null Write-Verbose "Withdrew eligibility '$($eligibility.displayName)'" } catch { & $record 'RoleEligibility' $eligibility.displayName $eligibility.id 'Failed' $_.Exception.Message Write-Warning ("Could not withdraw the eligibility '$($eligibility.displayName)': " + "$($_.Exception.Message). The role definition below will refuse to delete while it stands.") } } } # --- 1d. Custom directory roles ------------------------------------------------------ # Definitions only. This module never makes an active assignment, and the eligible schedules # it does create were withdrawn immediately above, so by this point nothing points at them. if ((& $shouldRun 'DirectoryRoles') -and -not $eligibilityReadFailed -and (& $permitted 'DirectoryRoles')) { Write-TestProgress -Activity 'Removing environment' -Status 'Custom directory roles' -PercentComplete 12 -ShowProgress:$ShowProgress foreach ($role in (& $enumerate 'DirectoryRoles' 'custom directory roles')) { if (-not $PSCmdlet.ShouldProcess($role.displayName, 'Delete custom directory role')) { continue } # Retried on the reference error, for the same reason the named locations below are: # withdrawing an eligibility is asynchronous, so the step above can report success # and the definition still be refused for a few seconds afterwards. Ordering the # steps correctly is necessary but not sufficient. & $deleteObject "/roleManagement/directory/roleDefinitions/$($role.id)" 'DirectoryRole' ` $role.displayName $role.id 'in use|referenced|active assignment|eligible' } } # --- 2. Named locations ------------------------------------------------------------- if ((& $shouldRun 'NamedLocations') -and (& $permitted 'NamedLocations')) { Write-TestProgress -Activity 'Removing environment' -Status 'Named locations' -PercentComplete 15 -ShowProgress:$ShowProgress foreach ($location in (& $enumerate 'NamedLocations' 'named locations')) { if (-not $PSCmdlet.ShouldProcess($location.displayName, 'Delete named location')) { continue } # A trusted location cannot be deleted while it is trusted. Verified against a # live tenant: the DELETE fails with error 1177, "cannot be deleted because it is # marked as a Trusted location. You have to unmark this named location before # deletion." So unmark it first. The same shape as the licensed group below, and # like that one it is only discoverable by trying. if ($location.isTrusted) { try { Invoke-EntraRequest -Method PATCH -Path "/identity/conditionalAccess/namedLocations/$($location.id)" ` -RetryOnNotFound -Connection $connection -Body @{ '@odata.type' = '#microsoft.graph.ipNamedLocation' isTrusted = $false } | Out-Null Write-Verbose "Unmarked '$($location.displayName)' as trusted so it can be deleted" } catch { Write-Warning ("Could not unmark '$($location.displayName)' as trusted: $($_.Exception.Message). " + 'The delete below will fail.') } } # Retried on both of the states that block a delete, because Conditional Access # clears both asynchronously and ordering the steps correctly is necessary but not # sufficient: # 1178 - still referenced by a policy, which was deleted in the step above # 1177 - still marked trusted, which was cleared by the PATCH immediately above # Verified live: the PATCH succeeds, isTrusted reads back False, and the DELETE # issued straight afterwards is still refused for a few seconds. & $deleteObject "/identity/conditionalAccess/namedLocations/$($location.id)" 'NamedLocation' ` $location.displayName $location.id 'referenced by one or more Conditional Access policies|marked as a Trusted location' } } # --- 3. Licences -------------------------------------------------------------------- # Before groups, and not optional if groups are being removed: Entra refuses to delete a # group that still holds one. if (((& $shouldRun 'Licenses') -or (& $shouldRun 'Groups')) -and (& $permitted 'Licenses')) { Write-TestProgress -Activity 'Removing environment' -Status 'Licences' -PercentComplete 25 -ShowProgress:$ShowProgress foreach ($group in (& $enumerate 'Groups' 'groups')) { $skuIds = @($group.assignedLicenses | ForEach-Object { $_.skuId } | Where-Object { $_ }) if (-not $skuIds) { continue } if (-not $PSCmdlet.ShouldProcess($group.displayName, "Remove $($skuIds.Count) licence(s)")) { continue } try { Invoke-EntraRequest -Method POST -Path "/groups/$($group.id)/assignLicense" -RetryOnNotFound -Connection $connection -Body @{ addLicenses = @() removeLicenses = $skuIds } | Out-Null & $record 'GroupLicense' $group.displayName $group.id 'Removed' "$($skuIds.Count) SKU(s)" Write-Verbose "Removed $($skuIds.Count) licence(s) from '$($group.displayName)'" } catch { & $record 'GroupLicense' $group.displayName $group.id 'Failed' $_.Exception.Message Write-Warning ("Could not remove licences from '$($group.displayName)': $($_.Exception.Message). " + "Deleting the group will fail until they are gone.") } } # Group licence removal is asynchronous. Without this the group delete below races it # and fails on exactly the group the module just un-licensed. if (-not $WhatIfPreference -and $removed.Where({ $_.Type -eq 'GroupLicense' }, 'First')) { Write-Verbose 'Waiting for group licence removal to settle before deleting groups' Start-Sleep -Seconds 20 } } # --- 4. Applications and their service principals ------------------------------------ if ((& $shouldRun 'Applications') -and (& $permitted 'Applications')) { Write-TestProgress -Activity 'Removing environment' -Status 'Applications' -PercentComplete 45 -ShowProgress:$ShowProgress # Service principals first. Deleting the application removes its service principal # anyway, but doing it explicitly means an interrupted run never leaves an orphaned # enterprise application behind with nothing to delete it from. $principals = @(& $enumerate 'ServicePrincipals' 'service principals') $applications = @(& $enumerate 'Applications' 'applications') # Under Application.ReadWrite.OwnedBy the delete is refused for anything this identity # does not own, so the owners are read first and the rest set aside with the reason, # before any of them is prompted for. if ($capability -and $capability.ApplicationsOwnedOnly -and $capability.IdentityObjectId) { $splitPrincipals = Split-EntraOwnedObject -Object $principals -Type ServicePrincipals -IdentityObjectId $capability.IdentityObjectId -Connection $connection $splitApplications = Split-EntraOwnedObject -Object $applications -Type Applications -IdentityObjectId $capability.IdentityObjectId -Connection $connection foreach ($item in $splitPrincipals.Unowned) { & $record 'ServicePrincipal' $item.displayName $item.id 'Skipped' 'Not attempted: this identity holds Application.ReadWrite.OwnedBy and does not own it.' } foreach ($item in $splitApplications.Unowned) { & $record 'Application' $item.displayName $item.id 'Skipped' 'Not attempted: this identity holds Application.ReadWrite.OwnedBy and does not own it.' } $unownedCount = @($splitPrincipals.Unowned).Count + @($splitApplications.Unowned).Count if ($unownedCount -gt 0) { Write-Warning ("Skipping $unownedCount application object(s) this identity does not own; " + 'Application.ReadWrite.OwnedBy cannot delete them. Remove them with the identity that created them.') } $principals = @($splitPrincipals.Owned) $applications = @($splitApplications.Owned) } foreach ($principal in $principals) { if (-not $PSCmdlet.ShouldProcess($principal.displayName, 'Delete service principal')) { continue } & $deleteObject "/servicePrincipals/$($principal.id)" 'ServicePrincipal' $principal.displayName $principal.id } foreach ($application in $applications) { if (-not $PSCmdlet.ShouldProcess($application.displayName, 'Delete application')) { continue } & $deleteObject "/applications/$($application.id)" 'Application' $application.displayName $application.id } } # --- 5. Devices ---------------------------------------------------------------------- if ((& $shouldRun 'Devices') -and (& $permitted 'Devices')) { Write-TestProgress -Activity 'Removing environment' -Status 'Devices' -PercentComplete 60 -ShowProgress:$ShowProgress & $deleteMany '/devices' 'Device' (& $enumerate 'Devices' 'devices') 'displayName' } # --- 6. Groups ----------------------------------------------------------------------- if ((& $shouldRun 'Groups') -and (& $permitted 'Groups')) { Write-TestProgress -Activity 'Removing environment' -Status 'Groups' -PercentComplete 75 -ShowProgress:$ShowProgress & $deleteMany '/groups' 'Group' (& $enumerate 'Groups' 'groups') 'displayName' } # --- 7. Users ------------------------------------------------------------------------ if ((& $shouldRun 'Users') -and (& $permitted 'Users')) { Write-TestProgress -Activity 'Removing environment' -Status 'Users' -PercentComplete 88 -ShowProgress:$ShowProgress & $deleteMany '/users' 'User' (& $enumerate 'Users' 'users') 'userPrincipalName' } # --- 8. Administrative units ----------------------------------------------------------- # Genuinely last among the directory objects. A unit is a container rather than a parent - # verified live, deleting one leaves every member in place - so removing it first would # discard the authoritative record of what to delete and leave teardown guessing from # names alone. if ((& $shouldRun 'AdministrativeUnits') -and (& $permitted 'AdministrativeUnits')) { Write-TestProgress -Activity 'Removing environment' -Status 'Administrative units' -PercentComplete 92 -ShowProgress:$ShowProgress foreach ($unit in (& $enumerate 'AdministrativeUnits' 'administrative units')) { if (-not $PSCmdlet.ShouldProcess($unit.displayName, 'Delete administrative unit')) { continue } & $deleteObject "/directory/administrativeUnits/$($unit.id)" 'AdministrativeUnit' $unit.displayName $unit.id } } # --- 8b. The bootstrapped service app ------------------------------------------------ # Never swept up with everything else, and opt-in even here. This is the credential the # module authenticates with: removing it as part of an ordinary teardown would sever the # connection mid-run and leave whatever had not been deleted yet unreachable. It goes last # of all the directory objects, and only when explicitly asked for. if ($RemoveServiceApp) { Write-TestProgress -Activity 'Removing environment' -Status 'Bootstrapped service app' -PercentComplete 94 -ShowProgress:$ShowProgress $serviceApps = @() try { $serviceApps = @(Invoke-EntraRequest -Method GET -Path '/applications' -Connection $connection ` -Paginate -ConsistencyLevel -Query @{ '$filter' = "startswith(displayName,'$($marker.Prefix)')" '$select' = 'id,appId,displayName,tags' }) | Where-Object { @($_.tags) -contains 'EntraEnvironmentServiceApp' } } catch { Write-Warning "Could not look for the bootstrapped service app: $($_.Exception.Message)" } foreach ($serviceApp in $serviceApps) { if (-not $PSCmdlet.ShouldProcess($serviceApp.displayName, 'Delete the service app THIS MODULE AUTHENTICATES WITH')) { continue } try { foreach ($principal in @(Invoke-EntraRequest -Method GET -Path '/servicePrincipals' -Connection $connection ` -Paginate -ConsistencyLevel -Query @{ '$filter' = "appId eq '$($serviceApp.appId)'"; '$select' = 'id' })) { & $deleteObject "/servicePrincipals/$($principal.id)" 'ServiceAppPrincipal' $serviceApp.displayName $principal.id } } catch { Write-Warning "Could not remove the service app's principal: $($_.Exception.Message)" } & $deleteObject "/applications/$($serviceApp.id)" 'ServiceApp' $serviceApp.displayName $serviceApp.id Write-Warning ("Removed '$($serviceApp.displayName)'. The credential this session is using no longer " + "exists; reconnect with -Interactive and run New-EntraServiceApp to bootstrap again.") } } # --- 9. Recycle bin ------------------------------------------------------------------ if ($PurgeRecycleBin) { Write-TestProgress -Activity 'Removing environment' -Status 'Purging recycle bin' -PercentComplete 95 -ShowProgress:$ShowProgress # The bin is read after a pause, because an object deleted seconds ago has not # necessarily appeared in it yet, and purging a bin that looks empty leaves the group # names reserved for the thirty days this switch exists to avoid. if (-not $WhatIfPreference) { Write-Verbose 'Waiting for deleted objects to appear in the recycle bin' Start-Sleep -Seconds 20 } foreach ($type in 'user', 'group', 'application') { $binned = @() try { $binned = @((Invoke-EntraRequest -Method GET -Path "/directory/deletedItems/microsoft.graph.$type" -Connection $connection -Paginate)) } catch { Write-Warning "Could not read the $type recycle bin: $($_.Exception.Message)" continue } foreach ($item in $binned) { # A soft-deleted user's UPN is rewritten with its id prefixed, so the prefix # is no longer at the start of it. Both the display name and a contains match # on the UPN are checked, and the tag is accepted as proof on its own. # # The third clause is for the B2B guests, and neither of the first two can # reach them: seeded people carry real display names rather than prefixed ones, # and a B2B UPN always lands on the tenant's initial onmicrosoft.com domain # rather than on the seed domain. Connect with -UpnSuffix naming a custom # verified domain and the second clause misses every guest, which leaves them # in the bin for thirty days with their names still reserved. This is the same # widening Get-EntraSeededObject makes for the same reason, and it is safe for # the same reason: #EXT# is minted by Entra, so a genuine partner guest would # need an email address that literally begins with the seed prefix. $isOurs = ($item.displayName -and $item.displayName.StartsWith($marker.Prefix, [StringComparison]::OrdinalIgnoreCase)) -or ($item.userPrincipalName -and $item.userPrincipalName -like "*$($marker.Prefix)*@$($marker.UpnSuffix)") -or ($item.userPrincipalName -and $item.userPrincipalName -like "*$($marker.Prefix)*#EXT#@*") if (-not $isOurs) { Write-Verbose "Leaving $type '$($item.displayName)' in the recycle bin; it is not ours." continue } if (-not $PSCmdlet.ShouldProcess($item.displayName, "Permanently purge $type from the recycle bin")) { continue } & $deleteObject "/directory/deletedItems/$($item.id)" "Purged$type" $item.displayName $item.id } } } Write-TestProgress -Activity 'Removing environment' -Completed -ShowProgress:$ShowProgress $result = [PSCustomObject]@{ PSTypeName = 'EntraTeardownResult' TenantId = $connection.TenantId TenantName = $connection.TenantName Prefix = $marker.Prefix RemovedCount = $removed.Count SkippedCount = $skipped.Count Removed = $removed.ToArray() Skipped = $skipped.ToArray() } if ($skipped.Count -gt 0) { Write-Warning ("$($skipped.Count) object(s) were not removed. Inspect the Skipped collection with " + "-PassThru to see why.") } Write-Verbose "Teardown complete: $($removed.Count) removed, $($skipped.Count) not removed" if ($PassThru) { return $result } } |