Providers/Entra/Public/New-EntraGroup.ps1

function New-EntraGroup {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates the seeded groups defined in Data\EntraGroups.csv, and their membership
    #>


    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('EntraGroup')]
    param(
        [Parameter()]
        [string[]]$GroupKey,

        [Parameter()]
        [ValidateSet('Core', 'Bulk')]
        [string[]]$Tier,

        [Parameter()]
        [switch]$SkipMembership,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-EntraConnection
    $marker = Get-EntraSeedMarker -Connection $connection

    $definitions = @(Get-EntraSeedData -Name 'EntraGroups')
    if ($Tier) { $definitions = @($definitions | Where-Object { $Tier -contains $_.Tier }) }
    if ($GroupKey) {
        $definitions = @($definitions | Where-Object { $GroupKey -contains $_.Key })
        $missing = @($GroupKey | Where-Object { $definitions.Key -notcontains $_ })
        if ($missing) {
            Write-Error "No seed definition for group key(s): $($missing -join ', ')" -ErrorAction Stop
            return
        }
    }

    Write-Verbose "Creating $($definitions.Count) group(s)"

    # Entra places no uniqueness constraint on a group's displayName, so re-running would
    # cheerfully create a second copy of every group - verified live, a re-run produced 103
    # duplicates. Users and devices collide on their own natural keys (userPrincipalName and
    # alternativeSecurityIds) and are idempotent for free; groups have to be checked.
    $existingByName = @{}
    foreach ($group in (Get-EntraSeededObject -Type Groups -Connection $connection)) {
        $existingByName[$group.displayName] = $group
    }
    if ($existingByName.Count -gt 0) {
        Write-Verbose "$($existingByName.Count) seeded group(s) already exist and will be reused"
    }

    # --- Phase 1: create ---------------------------------------------------------------
    $createRequests = [System.Collections.Generic.List[object]]::new()
    $definitionByKey = @{}
    $idByKey = @{}

    foreach ($definition in $definitions) {
        $displayName = '{0}{1}' -f $marker.Prefix, $definition.DisplayName
        $definitionByKey[$definition.Key] = $definition

        if ($existingByName.ContainsKey($displayName)) {
            Write-Verbose "Group '$displayName' already exists; reusing it"
            $idByKey[$definition.Key] = $existingByName[$displayName].id
            continue
        }

        # Validated before the switch rather than in a default branch. 'continue' inside a
        # PowerShell switch breaks out of the SWITCH, not the enclosing foreach, so rejecting
        # an unsupported kind from a default case falls straight through and creates it anyway.
        if ($definition.GroupKind -notin @('Security', 'Unified')) {
            Write-Error ("Group '$($definition.Key)' declares GroupKind '$($definition.GroupKind)', which " +
                "Graph cannot create. Only Security and Unified are possible; distribution lists and " +
                "mail-enabled security groups require Exchange Online PowerShell.") -ErrorAction Continue
            continue
        }

        $body = @{
            displayName  = $displayName
            # Not decoration. This carries the seed tag, and teardown requires both it and the
            # name prefix before it will delete a group found outside the container.
            description  = $marker.Description
            mailNickname = ('{0}{1}' -f $marker.Prefix, $definition.Key) -replace '[^A-Za-z0-9]', ''
        }

        switch ($definition.GroupKind) {
            'Unified' {
                $body.groupTypes = @('Unified')
                $body.mailEnabled = $true
                $body.securityEnabled = $false
            }
            'Security' {
                $body.groupTypes = @()
                $body.mailEnabled = $false
                $body.securityEnabled = $true
            }
        }

        if ($definition.MembershipType -eq 'Dynamic') {
            if (-not $definition.MembershipRule) {
                Write-Error "Group '$($definition.Key)' is marked Dynamic but carries no MembershipRule." -ErrorAction Continue
                continue
            }

            # DynamicMembership is added to whatever groupTypes the flavour already needs, not
            # substituted for it: a dynamic Microsoft 365 group is both.
            $body.groupTypes = @($body.groupTypes + 'DynamicMembership')

            $rule = $definition.MembershipRule.Replace('{Prefix}', $marker.Prefix)
            if ($rule -notlike "*$($marker.Prefix)*") {
                Write-Error ("Dynamic group '$($definition.Key)' has a membership rule that does not scope itself " +
                    "to the seed prefix, so it would match objects this module did not create. Refusing to " +
                    "create it. Rule: $rule") -ErrorAction Continue
                continue
            }

            $body.membershipRule = $rule
            $body.membershipRuleProcessingState = 'On'
        }

        if ([bool]::Parse($definition.IsAssignableToRole)) {
            # Immutable after creation, and it changes how the group is protected: only
            # Privileged Role Administrators can manage it afterwards.
            $body.isAssignableToRole = $true
        }

        if (-not $PSCmdlet.ShouldProcess($displayName, 'Create group')) { continue }

        $createRequests.Add([PSCustomObject]@{
                Reference = $definition.Key
                Method    = 'POST'
                Url       = '/groups'
                Body      = $body
            })
    }

    if ($createRequests.Count -gt 0) {
        $createResults = @(Invoke-EntraBatch -Request $createRequests.ToArray() -Connection $connection `
                -Activity 'Seeding groups' -ShowProgress:$ShowProgress)

        $newCount = 0
        foreach ($result in $createResults) {
            if ($result.Success -and $result.Body.id) { $idByKey[$result.Reference] = $result.Body.id; $newCount++ }
            else { Write-Warning "Could not create group '$($result.Reference)': $($result.Error)" }
        }
        Write-Verbose "Created $newCount of $($createRequests.Count) new group(s)"
    }

    if ($idByKey.Count -eq 0) {
        Write-TestProgress -Activity 'Seeding groups' -Completed -ShowProgress:$ShowProgress
        return
    }

    # --- Phase 2: membership -----------------------------------------------------------
    if (-not $SkipMembership -and $idByKey.Count -gt 0) {
        # Users are resolved once for the whole step rather than per group. At this volume the
        # same person appears in a dozen groups, and a lookup each would dominate the phase.
        $userIdByKey = @{}
        foreach ($user in (Get-EntraSeededObject -Type Users -Connection $connection)) {
            $upn = $user.userPrincipalName
            if ($upn -match "^$([regex]::Escape($marker.Prefix))(?<key>.+?)@") {
                $userIdByKey[$Matches['key']] = $user.id
            }
        }
        Write-Verbose "Resolved $($userIdByKey.Count) seeded user(s) for membership"

        $memberRequests = [System.Collections.Generic.List[object]]::new()
        $missingMembers = 0

        foreach ($key in $idByKey.Keys) {
            $definition = $definitionByKey[$key]

            # Entra owns the membership of a dynamic group. Adding a member by hand is
            # rejected, and would be overwritten by the next evaluation if it were not.
            if ($definition.MembershipType -eq 'Dynamic') { continue }

            $memberIds = @(
                foreach ($member in ($definition.Members -split ';' | Where-Object { $_ })) {
                    $trimmed = $member.Trim()
                    if ($userIdByKey.ContainsKey($trimmed)) { $userIdByKey[$trimmed] } else { $missingMembers++ }
                }
                foreach ($member in ($definition.MemberGroups -split ';' | Where-Object { $_ })) {
                    $trimmed = $member.Trim()
                    if ($idByKey.ContainsKey($trimmed)) { $idByKey[$trimmed] } else { $missingMembers++ }
                }
            )

            foreach ($memberId in $memberIds) {
                $memberRequests.Add([PSCustomObject]@{
                        Reference = "$key/$memberId"
                        Method    = 'POST'
                        Url       = "/groups/$($idByKey[$key])/members/`$ref"
                        Body      = @{ '@odata.id' = "$($connection.GraphBaseUri)/v1.0/directoryObjects/$memberId" }
                    })
            }
        }

        if ($missingMembers -gt 0) {
            Write-Warning "$missingMembers group member reference(s) named an object that does not exist and were skipped."
        }

        if ($memberRequests.Count -gt 0) {
            Write-Verbose "Adding $($memberRequests.Count) group membership(s)"
            $memberResults = @(Invoke-EntraBatch -Request $memberRequests.ToArray() -Connection $connection `
                    -RetryOnNotFound -Activity 'Adding group members' -ShowProgress:$ShowProgress)

            $duplicate = 'already exist|added object references already exist|A conflicting object'
            $failed = @($memberResults | Where-Object { -not $_.Success -and $_.Error -notmatch $duplicate })
            if ($failed.Count -gt 0) {
                Write-Warning "$($failed.Count) group membership(s) could not be added. First error: $($failed[0].Error)"
            }
        }
    }

    # --- Phase 3: containment ----------------------------------------------------------
    if ($idByKey.Count -gt 0) {
        $unit = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection) |
            Where-Object { $_.displayName -eq ('{0}Groups' -f $marker.Prefix) } | Select-Object -First 1

        if ($unit) {
            $placed = Add-EntraUnitMember -UnitId $unit.id -ObjectId @($idByKey.Values) -Connection $connection `
                -Activity 'Placing groups in their administrative unit' -ShowProgress:$ShowProgress
            Write-Verbose "Placed $placed group(s) in '$($unit.displayName)'"
        }
        else {
            Write-Warning ("No $($marker.Prefix)Groups administrative unit exists, so the created groups are not " +
                "contained. Run New-EntraAdministrativeUnit first.")
        }
    }

    Write-TestProgress -Activity 'Seeding groups' -Completed -ShowProgress:$ShowProgress

    if ($PassThru) {
        return @(foreach ($key in $idByKey.Keys) {
                $definition = $definitionByKey[$key]
                [PSCustomObject]@{
                    PSTypeName     = 'EntraGroup'
                    Key            = $key
                    Id             = $idByKey[$key]
                    DisplayName    = '{0}{1}' -f $marker.Prefix, $definition.DisplayName
                    GroupKind      = $definition.GroupKind
                    MembershipType = $definition.MembershipType
                    MembershipRule = $definition.MembershipRule
                    RoleAssignable = [bool]::Parse($definition.IsAssignableToRole)
                    Tier           = $definition.Tier
                    Purpose        = $definition.Purpose
                }
            })
    }
}