Providers/Entra/Public/New-EntraEnvironment.ps1
|
function New-EntraEnvironment { <# .SYNOPSIS Seeds the complete test environment, in the only order that works .DESCRIPTION Runs the fourteen seeding steps in dependency order. Each one also runs standalone, and the order here is not a preference - every step depends on the one before it: 1. Administrative units - the containers everything else is placed in, so they have to exist before there is anything to place. Skipping this step is possible and costs containment: the objects are still created and still carry the name prefix, but teardown then has only the weaker route to find them by. 2. Users - everything else references them 3. Groups - membership needs the users to exist, and nesting needs earlier groups 4. Guest users - the external identities, which join groups rather than being listed by them, so they follow the groups instead of travelling with the other users 5. Licences - assigned to a group and to users, so both must exist first 6. Devices - registered owners are users 7. Applications - assignments name both users and groups 8. Custom directory roles - definitions only, assigned to nobody 9. Role eligibilities - eligible schedules over those definitions, naming seeded principals and scoped to seeded units. Needs Entra ID P2; warns and continues without 10. Named locations - referenced by the policies below 11. Conditional Access policies - scoped to groups, conditioned on locations At AD parity this creates roughly eleven hundred objects. Everything that can be sent through Graph's $batch endpoint is, in chunks of twenty, which is the difference between a run of a few minutes and a run of well over an hour. A failing step does not stop the run. Steps are isolated because the common failure is a permission the app does not hold for one object type, and abandoning the whole environment over it would leave a half-seeded tenant that is harder to clean up than a complete one. What failed is reported, and the summary says which steps produced nothing. Re-running over an existing environment is safe but not a no-op. Objects whose names already exist will fail to create and be reported; memberships that already exist are recognised and skipped quietly. To rebuild cleanly, tear down first with -PurgeRecycleBin, because a soft-deleted group keeps its name reserved for thirty days and the re-seed will collide with it. .PARAMETER Skip Steps to leave out. Takes the same names as Remove-EntraEnvironment's -Keep. .PARAMETER SkuPartNumber Which SKU the licensing step should assign. Defaults to an automatically chosen one with free units. .PARAMETER ShowProgress Draws a progress bar .PARAMETER PassThru Returns a summary of everything created .OUTPUTS EntraEnvironmentResult when -PassThru is supplied .EXAMPLE PS> New-EntraEnvironment -WhatIf DESCRIPTION: Lists every object that would be created, and creates nothing OUTPUT: One What if line per object USE CASE: Always worth running first against a tenant that is in real use .EXAMPLE PS> New-EntraEnvironment -ShowProgress -PassThru DESCRIPTION: Seeds the full environment with a progress bar OUTPUT: The summary, including per-step counts and any failures USE CASE: The normal path .EXAMPLE PS> New-EntraEnvironment -Skip ConditionalAccessPolicies, NamedLocations DESCRIPTION: Seeds the directory and access layers but no policy objects OUTPUT: None USE CASE: A tenant where the app holds no Conditional Access write permission .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding(SupportsShouldProcess)] [OutputType('EntraEnvironmentResult')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Justification = 'The orchestrator creates nothing itself. Every object is created by a step function that calls ShouldProcess for it, and -WhatIf propagates to them through the common parameters, so calling ShouldProcess again here would only add a second prompt per object.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'SkuPartNumber', Justification = 'Used inside the Licenses step scriptblock, which the analyzer does not follow.')] param( [Parameter()] [ValidateSet('AdministrativeUnits', 'Users', 'GuestUsers', 'Groups', 'Licenses', 'Devices', 'Applications', 'NamedLocations', 'ConditionalAccessPolicies', 'DirectoryExtensions', 'DirectoryRoles', 'RoleEligibilities', 'AuthenticationStrengths', 'Containment')] [string[]]$Skip, [Parameter()] [ValidateNotNullOrEmpty()] [string]$SkuPartNumber, [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection Write-Verbose ("Seeding into tenant '$($connection.TenantName)' ($($connection.TenantId)) " + "under prefix '$($marker.Prefix)' on '$($marker.UpnSuffix)'") # Named rather than positional so that reordering them is a deliberate edit rather than a # diff that looks like a reformat. $steps = @( [PSCustomObject]@{ Name = 'AdministrativeUnits'; Action = { New-EntraAdministrativeUnit -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'Users'; Action = { New-EntraUser -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'Groups'; Action = { New-EntraGroup -PassThru:$true -ShowProgress:$ShowProgress } } # After the groups, because three of the four external identities are placed in one and # the group has to exist to be joined. This is the only step that runs out of the order # its object type would suggest: guests are users, but they join groups rather than # being listed by them, so they cannot go with the rest of the users. [PSCustomObject]@{ Name = 'GuestUsers'; Action = { New-EntraGuestUser -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'Licenses'; Action = { if ($SkuPartNumber) { Set-EntraLicense -SkuPartNumber $SkuPartNumber -PassThru:$true -ShowProgress:$ShowProgress } else { Set-EntraLicense -PassThru:$true -ShowProgress:$ShowProgress } } } [PSCustomObject]@{ Name = 'Devices'; Action = { New-EntraDevice -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'Applications'; Action = { New-EntraApplication -PassThru:$true -ShowProgress:$ShowProgress } } # After the applications, because the extension attributes are owned by an application # this step creates, and it places that application in the same unit as the others. [PSCustomObject]@{ Name = 'DirectoryExtensions'; Action = { New-EntraDirectoryExtension -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'DirectoryRoles'; Action = { New-EntraDirectoryRole -PassThru:$true -ShowProgress:$ShowProgress } } # After the roles it makes principals eligible for, and after the groups and units it # names as principal and scope. Eligible only, never active - see the function's help. # This is the one step that needs Entra ID P2; without it the step warns and the rest of # the environment is unaffected. [PSCustomObject]@{ Name = 'RoleEligibilities'; Action = { New-EntraRoleEligibility -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'NamedLocations'; Action = { New-EntraNamedLocation -PassThru:$true -ShowProgress:$ShowProgress } } # Before the policies, which reference a strength by name and fall back to a built-in # if the custom one does not exist yet. [PSCustomObject]@{ Name = 'AuthenticationStrengths'; Action = { New-EntraAuthenticationStrength -PassThru:$true -ShowProgress:$ShowProgress } } [PSCustomObject]@{ Name = 'ConditionalAccessPolicies'; Action = { New-EntraConditionalAccessPolicy -PassThru:$true -ShowProgress:$ShowProgress } } # Last, and not optional in practice. Placement happens seconds after each object is # created, so it loses 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. This pass reconciles the # difference once the directory has settled. [PSCustomObject]@{ Name = 'Containment'; Action = { Update-EntraContainment -PassThru:$true -ShowProgress:$ShowProgress } } ) $outcomes = [System.Collections.Generic.List[object]]::new() $stepIndex = 0 foreach ($step in $steps) { $stepIndex++ if ($Skip -contains $step.Name) { Write-Verbose "Skipping step $($step.Name)" $outcomes.Add([PSCustomObject]@{ PSTypeName = 'EntraEnvironmentStep' Step = $step.Name Status = 'Skipped' Count = 0 Items = @() Error = $null }) continue } Write-TestProgress -Activity 'Seeding Entra test environment' -Status $step.Name ` -PercentComplete ([int](100 * ($stepIndex - 1) / $steps.Count)) -ShowProgress:$ShowProgress try { $items = @(& $step.Action) $outcomes.Add([PSCustomObject]@{ PSTypeName = 'EntraEnvironmentStep' Step = $step.Name Status = 'Completed' Count = $items.Count Items = $items Error = $null }) Write-Verbose "Step $($step.Name) produced $($items.Count) object(s)" } catch { # Isolated on purpose. See the description: abandoning the run leaves a # half-seeded tenant, which is harder to clean up than a complete one. Write-Warning "Step '$($step.Name)' failed: $($_.Exception.Message)" $outcomes.Add([PSCustomObject]@{ PSTypeName = 'EntraEnvironmentStep' Step = $step.Name Status = 'Failed' Count = 0 Items = @() Error = $_.Exception.Message }) } } Write-TestProgress -Activity 'Seeding Entra test environment' -Completed -ShowProgress:$ShowProgress $failed = @($outcomes | Where-Object { $_.Status -eq 'Failed' }) if ($failed) { Write-Warning ("$($failed.Count) step(s) failed: $(($failed.Step) -join ', '). The rest of the " + "environment was still seeded.") } $result = [PSCustomObject]@{ PSTypeName = 'EntraEnvironmentResult' TenantId = $connection.TenantId TenantName = $connection.TenantName Prefix = $marker.Prefix UpnSuffix = $marker.UpnSuffix Steps = $outcomes.ToArray() TotalCount = ($outcomes | Measure-Object -Property Count -Sum).Sum } if (-not $WhatIfPreference) { Write-Verbose ("Seeding complete: $($result.TotalCount) object(s) across " + "$(@($outcomes | Where-Object Status -eq 'Completed').Count) step(s)") } if ($PassThru) { return $result } } |