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. Directory extensions - attributes on a schema application of their own, targeting users, groups and devices, which all exist by now 9. Custom directory roles - definitions only, assigned to nobody 10. Role eligibilities - eligible schedules over those definitions, naming seeded principals and scoped to seeded units. Needs Entra ID P2 or Entra ID Governance 11. Named locations - referenced by the policies below 12. Authentication strengths - referenced by the policies below 13. Conditional Access policies - scoped to groups, conditioned on locations. Needs Entra ID P1 14. Containment - places anything a replication race left outside its unit The two licence-gated steps are decided once, from the licences the connect read. A tenant without P1 skips the policies, one without P2 skips the eligibilities, each with one message naming the licence, rather than nine policy refusals and three eligibility warnings that each say the same thing in Graph's words. A tenant whose licences could not be read gets every step, and -IncludeUnlicensed attempts them regardless. This creates roughly 1,190 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 IncludeUnlicensed Attempt the Conditional Access and role eligibility steps even when the licences the connect read say the tenant cannot hold them .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]$IncludeUnlicensed, [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 } } ) # What the tenant cannot hold, decided once from the licences the connect read. A tenant # whose licences could not be read gets every step, so this can only remove noise, never a # step the tenant would have run. $unlicensed = [ordered]@{} $capability = $connection.Capabilities if (-not $IncludeUnlicensed -and $capability -and $capability.Known) { if (-not $capability.EntraP1) { $unlicensed['ConditionalAccessPolicies'] = 'Conditional Access needs Entra ID P1' } if (-not $capability.EntraP2) { $unlicensed['RoleEligibilities'] = 'Privileged Identity Management needs Entra ID P2 or Entra ID Governance' } foreach ($name in @($unlicensed.Keys)) { if ($Skip -contains $name) { $unlicensed.Remove($name) } } } if ($unlicensed.Count -gt 0) { $named = @($unlicensed.GetEnumerator() | ForEach-Object { '{0} ({1})' -f $_.Key, $_.Value }) -join ' and ' Write-Warning ("Skipping $named`: the tenant '$($connection.TenantName)' is not licensed for that. The rest of the " + 'environment is seeded regardless; -IncludeUnlicensed attempts the skipped step anyway.') } $outcomes = [System.Collections.Generic.List[object]]::new() $stepIndex = 0 foreach ($step in $steps) { $stepIndex++ $reason = $null if ($Skip -contains $step.Name) { $reason = 'Skipped by -Skip' } elseif ($unlicensed.Contains($step.Name)) { $reason = $unlicensed[$step.Name] } if ($reason) { Write-Verbose "Skipping step $($step.Name): $reason" $outcomes.Add([PSCustomObject]@{ PSTypeName = 'EntraEnvironmentStep' Step = $step.Name Status = 'Skipped' Count = 0 Items = @() Error = $null Reason = $reason }) 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 Reason = $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 Reason = $null }) } } 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 } } |