Providers/Okta/Public/New-OktaEnvironment.ps1

function New-OktaEnvironment {
    <#
    .SYNOPSIS
        Creates a complete Okta test environment: user types, schema, users, groups, group
        rules, apps, linked objects, zones, policies, origins, hooks and a service app

    .DESCRIPTION
        The single entry point. Runs twelve creation steps in the only order that works, because
        each one depends on the last:

         1. User types. A type must exist before its schema can be extended or a user assigned
            to it.
         2. Custom profile attributes, on every type's schema. Users cannot carry an attribute
            the schema does not define, and the seed tag teardown relies on is one of them.
         3. Users. Eight of them, which is what the Okta Integrator Free Plan's ten active user
            licence leaves room for once your own admin account is counted.
         4. Groups. Seventeen, because groups are not licence-limited and are where an eight
            user tenant gets its complexity back.
         5. Group rules. They need their target groups to exist and their source attributes to
            be populated.
         6. App integrations. They need the groups and users that get assigned to them, and
            they are what turn "who exists" into "who has access to what".
         7. Linked objects. They link users, so the users have to exist.
         8. Network zones. Policy rules reference them by id.
         9. Policies and their rules. They need both the groups they scope to and the zones
            their rules condition on.
        10. Trusted origins.
        11. Event hooks.
        12. The service app. Last, because it is the handover: everything above ran on the SSWS
            token you connected with, and from here on you can connect as the app instead and
            revoke that token.

        Every step is skippable and every step reports rather than throws, so one failure does
        not cost you the other eleven. The summary at the end says what actually happened.

    .PARAMETER Skip
        Components to skip. Valid values: UserTypes, Schema, Users, Groups, GroupRules, Apps,
        LinkedObjects, NetworkZones, Policies, TrustedOrigins, EventHooks, ServiceApp.

    .PARAMETER UserCount
        How many of the eight users to create. Lower it if the tenant already holds users.

    .PARAMETER AccountPassword
        Password for the seeded accounts. Defaults to a known, weak, shared lab value.

    .PARAMETER SkipLifecycleStates
        Create every user active rather than honouring the suspended and staged states

    .PARAMETER ServiceAppLabel
        Label for the service app

    .PARAMETER ServiceAppScope
        Okta API scopes to grant the service app

    .PARAMETER CredentialPath
        Where to write the service app credential

    .PARAMETER RevokeApiToken
        Name or id of an SSWS API token to revoke once the service app has proven it can issue
        a token, retiring the bootstrap credential in the same command that replaces it. The
        service app step runs last, so nothing else depends on the token by the time it goes.
        See New-OktaServiceApp for why the token has to be named rather than inferred.

    .PARAMETER UseSecretStore
        Store the service app private key in a SecretStore vault rather than encrypting it into
        the credential file. See New-OktaServiceApp for when that is worth doing.

    .PARAMETER VaultName
        Vault to use when -UseSecretStore is specified

    .PARAMETER VaultPassword
        Password for the vault when -UseSecretStore is specified

    .PARAMETER ActiveUserLimit
        The tenant's active user ceiling, checked before anything is created. Defaults to the
        value recorded at connect time.

    .PARAMETER Force
        Replace an existing service app with the same label

    .PARAMETER ShowProgress
        Emit per-step detail as verbose output

    .PARAMETER PassThru
        Return the detailed results object

    .OUTPUTS
        PSCustomObject summarising every operation, when -PassThru is used

    .EXAMPLE
        Connect-OktaEnvironment -OrgUrl https://trial-123456.okta.com -ApiToken $token
        New-OktaEnvironment
        The normal first run

    .EXAMPLE
        New-OktaEnvironment -WhatIf
        Shows every object that would be created, without creating any

    .EXAMPLE
        New-OktaEnvironment -Skip ServiceApp -UserCount 4
        Seeds a smaller environment and keeps using the SSWS token

    .EXAMPLE
        New-OktaEnvironment -Skip Schema, Users -PassThru
        Rebuilds only the groups and rules over users that already exist

    .NOTES
        Author: Jeffrey Stuhr
        Version: 1.0.0
        Last Updated: 2026-08-07

        REQUIREMENTS:
        - An Okta org and an SSWS API token belonging to a super admin
        - Connect-OktaEnvironment run first
        - Ten free active user slots minus whatever the tenant already holds

        The user count is the constraint the whole module is shaped around. Eight is not a
        sample size, it is what the tenant licence leaves room for, and every other object type
        is scaled up to compensate because none of them are capped.

    .LINK
        Connect-OktaEnvironment
        New-OktaProfileAttribute
        New-OktaUser
        New-OktaGroup
        New-OktaGroupRule
        New-OktaServiceApp
        Remove-OktaEnvironment
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Colour-coded console progress is intentional; results are returned as objects.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath',
        Justification = 'A file path, not a credential. The key it points at never appears here.')]
    # SupportsShouldProcess is declared so -WhatIf is accepted and propagates, but the decision
    # belongs to the step functions: each calls ShouldProcess per object, which is what makes
    # the preview name the users and groups instead of the steps.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '',
        Justification = 'Delegated to the step functions, which each call ShouldProcess per object.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('UserTypes', 'Schema', 'Users', 'Groups', 'GroupRules', 'Apps',
            'LinkedObjects', 'NetworkZones', 'Policies', 'TrustedOrigins', 'EventHooks',
            'ServiceApp')]
        [string[]]$Skip = @(),

        [Parameter()]
        [ValidateRange(1, 8)]
        [int]$UserCount = 8,

        [Parameter()]
        [System.Security.SecureString]$AccountPassword,

        [Parameter()]
        [switch]$SkipLifecycleStates,

        [Parameter()]
        [string]$ServiceAppLabel,

        [Parameter()]
        [string[]]$ServiceAppScope,

        [Parameter()]
        [string]$CredentialPath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$RevokeApiToken,

        [Parameter()]
        [switch]$UseSecretStore,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$VaultName = 'OktaEnvironment',

        [Parameter()]
        [System.Security.SecureString]$VaultPassword,

        [Parameter()]
        [int]$ActiveUserLimit,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

    begin {
        $correlationId = [Guid]::NewGuid()
        Write-Verbose "Starting New-OktaEnvironment - CorrelationId: $correlationId"

        $connection = Get-OktaConnection
        if (-not $PSBoundParameters.ContainsKey('ActiveUserLimit')) {
            $ActiveUserLimit = $connection.ActiveUserLimit
        }

        # The headroom check only matters if users are actually going to be created, and
        # asking for it when they are not would fail a groups-only rebuild on a full tenant.
        $requiredSlots = if ('Users' -in $Skip) { 0 } else { $UserCount }

        $prerequisiteArgs = @{
            CheckDataFiles    = $true
            RequiredUserSlots = $requiredSlots
            ActiveUserLimit   = $ActiveUserLimit
        }
        if (-not (Test-OktaPrerequisite @prerequisiteArgs)) {
            throw 'Prerequisites not met for Okta test environment creation'
        }
    }

    process {
        Write-TestMessage -Message "Okta Test Environment Creation ($($connection.OrgUrl))" -Type Header

        $results = [PSCustomObject]@{
            CorrelationId = $correlationId
            OrgUrl        = $connection.OrgUrl
            Prefix        = $connection.Prefix
            StartTime     = Get-Date
            EndTime       = $null
            Duration      = $null
            Operations    = [ordered]@{
                UserTypes      = @{ Attempted = $false; Success = $false; Results = $null }
                Schema         = @{ Attempted = $false; Success = $false; Results = $null }
                Users      = @{ Attempted = $false; Success = $false; Results = $null }
                Groups     = @{ Attempted = $false; Success = $false; Results = $null }
                GroupRules = @{ Attempted = $false; Success = $false; Results = $null }
                Apps           = @{ Attempted = $false; Success = $false; Results = $null }
                LinkedObjects  = @{ Attempted = $false; Success = $false; Results = $null }
                NetworkZones   = @{ Attempted = $false; Success = $false; Results = $null }
                Policies       = @{ Attempted = $false; Success = $false; Results = $null }
                TrustedOrigins = @{ Attempted = $false; Success = $false; Results = $null }
                EventHooks     = @{ Attempted = $false; Success = $false; Results = $null }
                ServiceApp = @{ Attempted = $false; Success = $false; Results = $null }
            }
            Summary       = [ordered]@{
                TotalOperations      = 0
                SuccessfulOperations = 0
                FailedOperations     = 0
            }
        }

        # Each step is the same shape: attempt, record, keep going. Declaring them as data
        # rather than repeating the try/catch five times keeps the ordering visible, which is
        # the part of this function that actually matters.
        $steps = @(
            @{
                Key   = 'UserTypes'
                Title = 'Step 0: Creating the second user type'
                Run   = { New-OktaUserType -PassThru -Confirm:$false }
                Report = { param($r) "$($r.CreatedTypes) created" }
            }
            @{
                Key   = 'Schema'
                Title = 'Step 1: Adding custom profile attributes'
                Run   = {
                    New-OktaProfileAttribute -PassThru -Confirm:$false
                }
                Report = { param($r) "Applied $(@($r.Applied).Count) attributes" }
            }
            @{
                Key   = 'Users'
                Title = "Step 2: Creating $UserCount users"
                Run   = {
                    $userArgs = @{ UserCount = $UserCount; PassThru = $true; Confirm = $false }
                    if ($AccountPassword)     { $userArgs.AccountPassword = $AccountPassword }
                    if ($SkipLifecycleStates) { $userArgs.SkipLifecycleStates = $true }
                    New-OktaUser @userArgs
                }
                Report = { param($r) "$($r.CreatedUsers) created, $($r.UpdatedUsers) updated" }
            }
            @{
                Key   = 'Groups'
                Title = 'Step 3: Creating groups and memberships'
                Run   = {
                    New-OktaGroup -PassThru -Confirm:$false
                }
                Report = { param($r) "$($r.CreatedGroups) created, $($r.MembersAdded) memberships" }
            }
            @{
                Key   = 'GroupRules'
                Title = 'Step 4: Creating group rules'
                Run   = {
                    New-OktaGroupRule -PassThru -Confirm:$false
                }
                Report = { param($r) "$($r.CreatedRules) created, $($r.ActivatedRules) activated" }
            }
            @{
                Key   = 'Apps'
                Title = 'Step 5: Creating app integrations and assignments'
                Run   = {
                    New-OktaApp -PassThru -Confirm:$false
                }
                Report = { param($r)
                    "$($r.CreatedApps) created, $($r.GroupsAssigned) group and " +
                    "$($r.UsersAssigned) direct assignments"
                }
            }
            @{
                Key   = 'LinkedObjects'
                Title = 'Step 6: Creating linked objects'
                Run   = { New-OktaLinkedObject -PassThru -Confirm:$false }
                Report = { param($r) "$($r.LinksCreated) links" }
            }
            @{
                Key   = 'NetworkZones'
                Title = 'Step 7: Creating network zones'
                Run   = { New-OktaNetworkZone -PassThru -Confirm:$false }
                Report = { param($r) "$($r.CreatedZones) created" }
            }
            @{
                Key   = 'Policies'
                Title = 'Step 8: Creating policies and rules'
                Run   = { New-OktaPolicy -PassThru -Confirm:$false }
                Report = { param($r) "$($r.CreatedPolicies) policies, $($r.RulesCreated) rules" }
            }
            @{
                Key   = 'TrustedOrigins'
                Title = 'Step 9: Creating trusted origins'
                Run   = { New-OktaTrustedOrigin -PassThru -Confirm:$false }
                Report = { param($r) "$($r.CreatedOrigins) created" }
            }
            @{
                Key   = 'EventHooks'
                Title = 'Step 10: Creating event hooks'
                Run   = { New-OktaEventHook -PassThru -Confirm:$false }
                Report = { param($r) "$($r.CreatedHooks) created" }
            }
            @{
                Key   = 'ServiceApp'
                Title = 'Step 11: Registering the service app'
                Run   = {
                    $appArgs = @{ PassThru = $true; Confirm = $false }
                    if ($ServiceAppLabel) { $appArgs.Label = $ServiceAppLabel }
                    if ($ServiceAppScope) { $appArgs.Scope = $ServiceAppScope }
                    if ($CredentialPath)  { $appArgs.CredentialPath = $CredentialPath }
                    if ($Force)           { $appArgs.Force = $true }
                    if ($RevokeApiToken)  { $appArgs.RevokeApiToken = $RevokeApiToken }
                    if ($UseSecretStore) {
                        $appArgs.UseSecretStore = $true
                        $appArgs.VaultName      = $VaultName
                        if ($VaultPassword) { $appArgs.VaultPassword = $VaultPassword }
                    }
                    New-OktaServiceApp @appArgs
                }
                Report = { param($r) "client_id $($r.ClientId), key protection $($r.Protection)" }
            }
        )

        $stepNumber = 0
        foreach ($step in $steps) {
            $stepNumber++

            if ($step.Key -in $Skip) {
                Write-TestMessage -Message "$($step.Title) - skipped as requested" -Type Warning
                continue
            }

            Write-TestMessage -Message $step.Title -Type Info
            $results.Operations[$step.Key].Attempted = $true
            $results.Summary.TotalOperations++

            # Deliberately no ShouldProcess gate here. Every step function implements its own,
            # and $WhatIfPreference propagates into them, so letting them run is what makes
            # -WhatIf list the eight users and seventeen groups by name. Gating at this level
            # instead produced a preview that said only "would perform step 2", which tells you
            # nothing you did not already know from reading the parameter.
            try {
                $stepResult = & $step.Run
                $results.Operations[$step.Key].Results = $stepResult

                # A step that did not throw has not necessarily worked. Every component function
                # collects what it could not do into an Errors property and returns normally, so
                # that one bad row does not abandon the other seventeen - which means "no
                # exception" says nothing about whether anything was created.
                #
                # Counting only thrown exceptions is how a real run reported "Operations
                # completed: 11/11" and "creation complete" while the custom user type, its ten
                # schema attributes and the two users belonging to it had all failed. The
                # errors were sitting in the returned object the whole time; nothing looked.
                $stepErrors = @()
                if ($stepResult -and ($stepResult.PSObject.Properties.Name -contains 'Errors')) {
                    $stepErrors = @($stepResult.Errors)
                }

                if ($stepErrors.Count -gt 0) {
                    $results.Operations[$step.Key].Success = $false
                    $results.Summary.FailedOperations++

                    foreach ($stepError in $stepErrors) {
                        Write-Error "$($step.Title): $stepError"
                    }
                }
                else {
                    $results.Operations[$step.Key].Success = $true
                    $results.Summary.SuccessfulOperations++
                }

                if ($ShowProgress -and $stepResult) {
                    Write-Verbose (& $step.Report $stepResult)
                }
            }
            catch {
                $results.Operations[$step.Key].Results = $_.Exception.Message
                $results.Summary.FailedOperations++
                Write-Error "$($step.Title) failed: $($_.Exception.Message)"
            }
        }

        $results.EndTime = Get-Date
        $results.Duration = $results.EndTime - $results.StartTime

        Write-TestMessage -Message 'Environment Creation Summary' -Type Header
        Write-Host ("Operations completed: $($results.Summary.SuccessfulOperations)/" +
            "$($results.Summary.TotalOperations)") -ForegroundColor Green
        Write-Host "Duration: $($results.Duration.ToString('hh\:mm\:ss'))" -ForegroundColor Green

        if ($results.Summary.FailedOperations -gt 0) {
            Write-Warning "Failed operations: $($results.Summary.FailedOperations)"
            Write-Warning 'Inspect the results object with -PassThru for the detail.'

            # Not "complete". A closing line that says success regardless of what happened is
            # the last thing a person reads, and it was overriding a screen of red above it.
            Write-TestMessage -Message ('Test environment creation finished with ' +
                "$($results.Summary.FailedOperations) failed step(s).") -Type Error
        }
        else {
            Write-TestMessage -Message 'Test environment creation complete.' -Type Success
        }

        if ($PassThru) { return $results }
    }

    end {
        Write-Verbose "Completed New-OktaEnvironment - CorrelationId: $correlationId"
    }
}