Public/New-NSPConditionalAccessTrustedLocationPlan.ps1

function New-NSPConditionalAccessTrustedLocationPlan {
    <#
    .SYNOPSIS
        Offline: plans adding or removing an office's public IP ranges in a trusted named location (creating it if needed).
    .DESCRIPTION
        Trusted locations are what the baseline policies call 'AllTrusted': NSP-BASE-MFA-01 asks
        for MFA only outside them, NSP-BASE-BLOCK-01 lets restricted users sign in only from them,
        and the registration policies (REG-01, REG-02) treat them as the office. They are IP
        named locations marked trusted, holding the office's public addresses.
 
        -Name picks the IP named location by display name. If none has that name, the plan
        creates it (trusted, with -AddRange). Otherwise it updates its ranges: -AddRange adds,
        -RemoveRange removes, and the result must not be empty. Bare addresses become /32 (or
        /128). -Untrust clears the trusted mark instead (the ranges stay).
 
        The plan has one item, Pending, with the policies that use trusted locations named in
        its reason, and warnings for private or very broad ranges and for removing the last
        trusted range. Review and run it like any plan; the run re-reads the location first and
        refuses if it changed since. Undo restores the previous ranges, or deletes a location
        the run created.
    .EXAMPLE
        $plan = New-NSPConditionalAccessTrustedLocationPlan -Inventory $inventory -Name 'Main office' -AddRange '203.0.113.10' -Save
        Set-NSPConditionalAccessPlanDecision -Plan $plan.Path -ItemId $plan.Items[0].ItemId -Decision Approve
    .EXAMPLE
        New-NSPConditionalAccessTrustedLocationPlan -Inventory $inventory -Name 'Main office' -AddRange '198.51.100.0/29' -RemoveRange '203.0.113.10/32' -Save
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Inventory,
        [Parameter(Mandatory)][string]$Name,
        [string[]]$AddRange,
        [string[]]$RemoveRange,
        [switch]$Untrust,
        [switch]$Save,
        [string]$ConfigRoot
    )

    if ($Inventory -is [string]) { $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json }
    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    $tenantId = Get-Value $Inventory 'TenantId'
    $Name = $Name.Trim()
    if (-not $Name) { throw 'A location name is required.' }

    $ipLocations = @(Get-Value $Inventory 'NamedLocations' | Where-Object { (Get-Value $_ '@odata.type') -eq '#microsoft.graph.ipNamedLocation' })
    $same = @($ipLocations | Where-Object { (Get-Value $_ 'displayName') -ceq $Name })
    if ($same.Count -gt 1) { throw "Several IP named locations are called '$Name'. Rename one in the Entra admin center, take a new inventory, and plan again." }
    $live = if ($same.Count) { $same[0] } else { $null }
    $otherNamed = @(Get-Value $Inventory 'NamedLocations' | Where-Object { (Get-Value $_ 'displayName') -ceq $Name -and (Get-Value $_ '@odata.type') -ne '#microsoft.graph.ipNamedLocation' })
    if (-not $live -and $otherNamed.Count) { throw "'$Name' is a country location, not an IP location. Choose another name." }

    $warnings = [System.Collections.Generic.List[string]]::new()
    $added = @(foreach ($range in @($AddRange | Where-Object { $_ })) {
            $parsed = ConvertTo-NSPConditionalAccessCidr -Range $range
            foreach ($concern in $parsed.Concerns) { $warnings.Add($concern) }
            $parsed
        })
    $removed = @(foreach ($range in @($RemoveRange | Where-Object { $_ })) { (ConvertTo-NSPConditionalAccessCidr -Range $range).Cidr })

    $current = @(if ($live) {
            Get-Value $live 'ipRanges' | Where-Object { $_ } | ForEach-Object { [pscustomobject]@{ Cidr = [string](Get-Value $_ 'cidrAddress'); Type = [string](Get-Value $_ '@odata.type') } }
        })
    foreach ($cidr in $removed) {
        if ($cidr -notin @($current | ForEach-Object { $_.Cidr })) { throw "$cidr is not in '$Name' ($(@($current | ForEach-Object { $_.Cidr }) -join ', '))." }
    }
    $ranges = [System.Collections.Generic.List[object]]::new()
    foreach ($range in $current) { if ($range.Cidr -notin $removed) { $ranges.Add($range) } }
    foreach ($range in $added) { if ($range.Cidr -notin @($ranges | ForEach-Object { $_.Cidr })) { $ranges.Add($range) } }
    if (-not $ranges.Count) { throw "'$Name' would have no ranges left. Keep at least one, or delete the location in the Entra admin center." }

    $trusted = -not $Untrust
    $desired = [ordered]@{
        '@odata.type' = '#microsoft.graph.ipNamedLocation'
        displayName = $Name
        isTrusted = $trusted
        ipRanges = @($ranges | ForEach-Object { [ordered]@{ '@odata.type' = $_.Type; cidrAddress = $_.Cidr } })
    }
    $desiredKey = Get-NSPConditionalAccessIpLocationKey -Location $desired
    $liveKey = if ($live) { Get-NSPConditionalAccessIpLocationKey -Location $live } else { $null }

    # Which policies this affects: any that include or exclude trusted locations.
    $users = @(Get-Value $Inventory 'Policies' | Where-Object {
            $locations = Get-Value (Get-Value $_ 'conditions') 'locations'
            'AllTrusted' -in @(@(Get-Value $locations 'includeLocations') + @(Get-Value $locations 'excludeLocations'))
        } | ForEach-Object { "$(Get-Value $_ 'displayName') [$(Get-Value $_ 'state')]" })
    $usedBy = if ($users.Count) { "Policies that use trusted locations: $($users -join '; ')." } else { 'No policy uses trusted locations yet; NSP-BASE-MFA-01, BLOCK-01, REG-01, and REG-02 will once deployed.' }

    $otherTrusted = @($ipLocations | Where-Object { (Get-Value $_ 'isTrusted') -and (Get-Value $_ 'displayName') -cne $Name })
    if (-not $trusted -and -not $otherTrusted.Count) {
        $warnings.Add('This is the last trusted location. Without one, NSP-BASE-BLOCK-01 blocks its members everywhere and NSP-BASE-MFA-01 asks for MFA everywhere.')
    }

    $differences = @()
    if ($live) {
        $liveRanges = @($current | ForEach-Object { $_.Cidr })
        $desiredRanges = @($ranges | ForEach-Object { $_.Cidr })
        if ((($liveRanges | Sort-Object) -join ',') -ne (($desiredRanges | Sort-Object) -join ',')) {
            $differences += [pscustomobject]@{ Field = 'ipRanges'; Live = $liveRanges; Desired = $desiredRanges }
        }
        if ([bool](Get-Value $live 'isTrusted') -ne $trusted) {
            $differences += [pscustomobject]@{ Field = 'isTrusted'; Live = @([bool](Get-Value $live 'isTrusted')); Desired = @($trusted) }
        }
    }

    $action = if (-not $live) { 'CreateTrustedLocation' } elseif ($liveKey -eq $desiredKey) { 'NoChange' } else { 'UpdateTrustedLocation' }
    $reason = switch ($action) {
        'CreateTrustedLocation' { "Creates the trusted IP location '$Name' with $(@($ranges | ForEach-Object { $_.Cidr }) -join ', '). $usedBy" }
        'UpdateTrustedLocation' { "Changes '$Name': $(@($differences | ForEach-Object { "$($_.Field) $(@($_.Live) -join ', ') -> $(@($_.Desired) -join ', ')" }) -join '; '). $usedBy" }
        'NoChange' { "'$Name' already has exactly these ranges." }
    }
    $item = [pscustomobject][ordered]@{
        ItemId = "trustedlocation:$Name"
        Kind = 'NamedLocation'
        Action = $action
        Decision = if ($action -eq 'NoChange') { 'NotActionable' } else { 'Pending' }
        DefinitionId = $null
        DisplayName = $Name
        Confidence = $null
        Score = $null
        LiveObjectId = if ($live) { Get-Value $live 'id' } else { $null }
        LiveDisplayName = if ($live) { $Name } else { $null }
        LiveState = $null
        DependsOn = @()
        Reason = $reason
        Differences = @($differences)
        Warnings = @($warnings)
        Impact = $null
        DesiredBody = $desired
        DesiredHash = $desiredKey
        LiveHash = $liveKey
    }

    $now = (Get-Date).ToUniversalTime()
    $plan = [pscustomobject][ordered]@{
        SchemaVersion = 1
        PlanKind = 'TrustedLocation'
        PlanId = [guid]::NewGuid().ToString()
        CreatedAtUtc = $now.ToString('o')
        TenantId = $tenantId
        TenantDomain = Get-Value $Inventory 'TenantDomain'
        InventoryCapturedAtUtc = Get-Value $Inventory 'CapturedAtUtc'
        Items = @($item)
        Path = $null
    }
    if ($Save -and $PSCmdlet.ShouldProcess($tenantId, 'Save trusted location plan')) {
        $plan.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $tenantId) ('location-' + $now.ToString('yyyyMMdd-HHmmss') + '.json')
        Save-NSPConditionalAccessJson -InputObject $plan -Path $plan.Path
    }
    $plan
}