Providers/Entra/Public/New-EntraNamedLocation.ps1

function New-EntraNamedLocation {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates the named locations Conditional Access policies condition on
    #>


    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('EntraNamedLocation')]
    param(
        [Parameter()]
        [string[]]$LocationKey,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

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

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

    # Entra places no uniqueness constraint on a named location's displayName, so without this
    # a re-run creates a second copy of every one of them.
    $existingByName = @{}
    foreach ($location in (Get-EntraSeededObject -Type NamedLocations -Connection $connection)) {
        $existingByName[$location.displayName] = $location
    }

    $created = [System.Collections.Generic.List[object]]::new()
    $index = 0

    foreach ($definition in $definitions) {
        $index++
        $displayName = '{0}{1}' -f $marker.Prefix, $definition.DisplayName

        Write-TestProgress -Activity 'Seeding named locations' -Status $displayName `
            -PercentComplete ([int](100 * $index / [Math]::Max(1, $definitions.Count))) -ShowProgress:$ShowProgress

        if ($existingByName.ContainsKey($displayName)) {
            Write-Verbose "Named location '$displayName' already exists; reusing it"
            $created.Add([PSCustomObject]@{
                    PSTypeName   = 'EntraNamedLocation'
                    Key          = $definition.Key
                    Id           = $existingByName[$displayName].id
                    DisplayName  = $displayName
                    LocationType = $definition.LocationType
                    Values       = @($definition.Value -split ';' | Where-Object { $_ })
                    IsTrusted    = ($definition.LocationType -ne 'Country' -and [bool]::Parse($definition.IsTrusted))
                    Purpose      = $definition.Purpose
                })
            continue
        }

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

        $values = @($definition.Value -split ';' | Where-Object { $_ } | ForEach-Object { $_.Trim() })

        $body = if ($definition.LocationType -eq 'Country') {
            @{
                '@odata.type'                     = '#microsoft.graph.countryNamedLocation'
                displayName                       = $displayName
                countriesAndRegions               = $values
                # Stated explicitly rather than left to default, because the two behave
                # differently and the difference is invisible unless it is set on purpose: a
                # sign-in Entra cannot geolocate matches a location with this set and misses
                # one without it, however long the country list is.
                includeUnknownCountriesAndRegions = [bool]::Parse($definition.IncludeUnknown)
            }
        }
        else {
            @{
                '@odata.type' = '#microsoft.graph.ipNamedLocation'
                displayName   = $displayName
                isTrusted     = [bool]::Parse($definition.IsTrusted)
                ipRanges      = @(foreach ($range in $values) {
                        # The OData type is per-range, not per-location, and Graph rejects a
                        # v4 range declared as v6 rather than inferring it.
                        $type = if ($range -like '*:*') { '#microsoft.graph.iPv6CidrRange' } else { '#microsoft.graph.iPv4CidrRange' }
                        @{ '@odata.type' = $type; cidrAddress = $range }
                    })
            }
        }

        try {
            $location = Invoke-EntraRequest -Method POST -Path '/identity/conditionalAccess/namedLocations' -Body $body
        }
        catch {
            Write-Error "Failed to create named location '${displayName}': $($_.Exception.Message)"
            continue
        }

        $created.Add([PSCustomObject]@{
                PSTypeName   = 'EntraNamedLocation'
                Key          = $definition.Key
                Id           = $location.id
                DisplayName  = $displayName
                LocationType = $definition.LocationType
                Values       = $values
                IsTrusted    = ($definition.LocationType -ne 'Country' -and [bool]::Parse($definition.IsTrusted))
                Purpose      = $definition.Purpose
            })

        Write-Verbose "Created named location '$displayName' ($($location.id))"
    }

    Write-TestProgress -Activity 'Seeding named locations' -Completed -ShowProgress:$ShowProgress

    if ($PassThru) { return $created.ToArray() }
}