Public/Import-NSPConditionalAccessTravel.ps1

function Import-NSPConditionalAccessTravel {
    <#
    .SYNOPSIS
        Turns a tenant's existing per-country travel policies into travel entries, so the tool adopts them instead of creating new ones.
    .DESCRIPTION
        Offline; changes only the tenant profile (with -WhatIf, nothing). Reads an inventory and
        recognizes the legacy travel model: one block policy per country, applying to exactly one
        group, for all applications, everywhere except the home country and that one country.
 
        For each one it adds a country entry bound to that group (its name becomes the entry's
        DefaultName), binds the country's named location if none is bound, and records the
        policy as ImportedPolicyId. The next deployment plan then proposes adopting that policy
        as NSP-TRAVEL-<ISO>-01 (AdoptOrReview) instead of creating one. Because an enabled
        imported policy already restricts its group, the group counts as Enforced at once: an
        adopted NSP-BASE-GEO-01 keeps excluding it.
 
        A shared MFA-and-session policy for those groups (MFA plus a sign-in frequency, scoped
        only to groups) is recorded as ImportedSessionPolicyId for NSP-TRAVEL-SESSION-01. Its
        frequency becomes Travel.SessionHours (days times 24, at most 720).
 
        Nothing is guessed. Policies that look like travel but do not fit are reported with the
        reason and left alone, as are groups that look like travel groups but no policy uses.
    .EXAMPLE
        Import-NSPConditionalAccessTravel -Inventory $inventoryPath -TenantProfile $profilePath -WhatIf
    .EXAMPLE
        Import-NSPConditionalAccessTravel -Inventory $inventoryPath -TenantProfile $profilePath | Format-Table Result, Policy, Key, Group, Notes -Wrap
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Inventory,
        [Parameter(Mandatory)][string]$TenantProfile
    )

    if ($Inventory -is [string]) { $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json }
    $content = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json
    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    function Get-List { param($Object, [string]$Key) @(Get-NSPConditionalAccessMapValue $Object $Key | Where-Object { $null -ne $_ }) }

    if ((Get-Value $Inventory 'TenantId') -ne $content.TenantId) {
        throw "The tenant profile is for $($content.TenantId) but the inventory is from $(Get-Value $Inventory 'TenantId')."
    }
    $homeCountry = [string]$content.HomeCountry
    if (-not $homeCountry) { throw 'The tenant profile has no HomeCountry.' }

    $groupById = @{}
    foreach ($group in Get-List $Inventory 'Groups') { $groupById[[string](Get-Value $group 'id')] = $group }
    $countryOf = @{}
    foreach ($location in Get-List $Inventory 'NamedLocations') {
        if ((Get-Value $location '@odata.type') -ne '#microsoft.graph.countryNamedLocation') { continue }
        $codes = @(Get-List $location 'countriesAndRegions')
        if ($codes.Count -eq 1) { $countryOf[[string](Get-Value $location 'id')] = [string]$codes[0] }
    }
    if (-not $content.Travel) { $content | Add-Member -NotePropertyName Travel -NotePropertyValue ([pscustomobject]@{ NextTripNumber = 1; Entries = [pscustomobject]@{} }) }
    if (-not $content.Travel.Entries) { $content.Travel | Add-Member -NotePropertyName Entries -NotePropertyValue ([pscustomobject]@{}) -Force }
    if (-not $content.NamedLocations.Countries) { $content.NamedLocations | Add-Member -NotePropertyName Countries -NotePropertyValue ([pscustomobject]@{}) -Force }
    $usedGroups = @{}
    foreach ($entry in $content.Travel.Entries.PSObject.Properties) { foreach ($id in @($entry.Value.Ids)) { $usedGroups[[string]$id] = $entry.Name } }

    $results = [System.Collections.Generic.List[object]]::new()
    function Add-Result {
        param([string]$Result, $Policy, [string]$Key, [string]$Group, [string[]]$Countries, [string[]]$Notes)
        $results.Add([pscustomobject]@{
                Result = $Result; Policy = if ($Policy) { [string](Get-Value $Policy 'displayName') } else { $null }; Key = $Key
                Group = $Group; Countries = @($Countries); Notes = (@($Notes | Where-Object { $_ }) -join ' ')
            })
    }

    $policies = @(Get-List $Inventory 'Policies' | Where-Object { -not (Get-Value $_ 'templateId') })
    $imported = [System.Collections.Generic.List[object]]::new()
    foreach ($policy in $policies) {
        $conditions = Get-Value $policy 'conditions'
        $users = Get-Value $conditions 'users'
        $locations = Get-Value $conditions 'locations'
        $includeGroups = @(Get-List $users 'includeGroups')
        $isTravelShape = @(Get-List (Get-Value $policy 'grantControls') 'builtInControls') -contains 'block' -and
            $includeGroups.Count -eq 1 -and -not @(Get-List $users 'includeUsers').Count -and -not @(Get-List $users 'includeRoles').Count -and
            @(Get-List (Get-Value $conditions 'applications') 'includeApplications') -contains 'All' -and
            @(Get-List $locations 'includeLocations') -contains 'All'
        if (-not $isTravelShape) { continue }

        $groupId = [string]$includeGroups[0]
        $group = $groupById[$groupId]
        $groupName = if ($group) { [string](Get-Value $group 'displayName') } else { $groupId }
        $excludedLocations = @(Get-List $locations 'excludeLocations')
        $isos = @($excludedLocations | ForEach-Object { $countryOf[[string]$_] })
        $notes = [System.Collections.Generic.List[string]]::new()
        if (-not $excludedLocations.Count) {
            Add-Result Skipped $policy -Group $groupName -Notes "It excludes no location, so it blocks '$groupName' everywhere, including $homeCountry. Fix or remove it in the tenant; nothing was imported."
            continue
        }
        if (@($isos | Where-Object { -not $_ }).Count -ne 0 -or $isos.Count -ne $excludedLocations.Count) {
            Add-Result Skipped $policy -Group $groupName -Notes 'It excludes a location that is not a single-country location; not a travel policy this tool can express.'
            continue
        }
        if ($homeCountry -notin $isos) {
            Add-Result Skipped $policy -Group $groupName -Countries $isos -Notes "It does not allow the home country ($homeCountry); not the travel model."
            continue
        }
        $abroad = @($isos | Where-Object { $_ -ne $homeCountry } | Sort-Object -Unique)
        if ($abroad.Count -ne 1) {
            Add-Result Skipped $policy -Group $groupName -Countries $abroad -Notes "It allows $($abroad.Count) countries besides $homeCountry. A standing entry covers one country; add a trip, or split the policy."
            continue
        }
        $iso = $abroad[0]
        if (-not $group) { Add-Result Skipped $policy $iso -Group $groupId -Notes 'Its group is not in the inventory.'; continue }
        if ($content.Travel.Entries.PSObject.Properties[$iso]) { Add-Result Skipped $policy $iso $groupName $iso -Notes "A travel entry for $iso already exists."; continue }
        if ($usedGroups.ContainsKey($groupId)) { Add-Result Skipped $policy $iso $groupName $iso -Notes "Group '$groupName' already belongs to travel entry $($usedGroups[$groupId])."; continue }

        if (@(Get-List $users 'excludeUsers').Count + @(Get-List $users 'excludeGroups').Count) {
            $notes.Add('It also excludes users or groups; adopting it replaces those with breakglass and service accounts. Review the differences in the plan.')
        }
        $countryLocation = @($excludedLocations | Where-Object { $countryOf[[string]$_] -eq $iso })[0]
        $bound = $content.NamedLocations.Countries.PSObject.Properties[$iso]
        if (-not $bound) {
            $content.NamedLocations.Countries | Add-Member -NotePropertyName $iso -NotePropertyValue ([string]$countryLocation)
            $notes.Add("Bound its $iso named location.")
        } elseif ([string]$bound.Value -ne [string]$countryLocation) {
            $notes.Add("A different $iso location is already bound; adopting switches the policy to it.")
        }
        $content.Travel.Entries | Add-Member -NotePropertyName $iso -NotePropertyValue ([pscustomobject][ordered]@{
                Kind = 'Country'; Name = $iso; Countries = @($iso)
                Ids = @($groupId); Labels = @($groupName); Missing = $false; DefaultName = $groupName
                ImportedPolicyId = [string](Get-Value $policy 'id'); CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
            })
        $usedGroups[$groupId] = $iso
        $imported.Add([pscustomobject]@{ Iso = $iso; GroupId = $groupId; GroupName = $groupName; Enabled = (Get-Value $policy 'state') -eq 'enabled' })
        Add-Result Imported $policy $iso $groupName $iso -Notes $notes.ToArray()
    }

    # The home-country block (NSP-BASE-GEO-01's legacy names) should exclude every enabled travel
    # group; a group it does not exclude is blocked abroad today whatever its travel policy says.
    if ($imported.Count) {
        $geoKeys = @(Get-NSPConditionalAccessDefinition -Id 'NSP-BASE-GEO-01' | ForEach-Object { $_['LegacyNames'] } | ForEach-Object { ConvertTo-NSPConditionalAccessNameKey $_ })
        $geo = @($policies | Where-Object { (ConvertTo-NSPConditionalAccessNameKey ([string](Get-Value $_ 'displayName'))) -in $geoKeys })
        if ($geo.Count -eq 1) {
            $geoExcluded = @(Get-List (Get-Value (Get-Value $geo[0] 'conditions') 'users') 'excludeGroups')
            foreach ($entry in @($imported | Where-Object { $_.GroupId -notin $geoExcluded })) {
                $row = @($results | Where-Object { $_.Key -eq $entry.Iso -and $_.Result -eq 'Imported' })[0]
                $row.Notes = ("$($row.Notes) '$(Get-Value $geo[0] 'displayName')' does not exclude '$($entry.GroupName)', so its members are blocked in $($entry.Iso) today. Adopting NSP-BASE-GEO-01 fixes that.").Trim()
            }
        }
    }

    # The shared travel MFA and session policy.
    $travelIds = @($content.Travel.Entries.PSObject.Properties | ForEach-Object { @($_.Value.Ids) } | ForEach-Object { [string]$_ })
    if ($travelIds.Count -and -not $content.Travel.PSObject.Properties['ImportedSessionPolicyId']) {
        $sessions = @($policies | Where-Object {
                $conditions = Get-Value $_ 'conditions'
                $users = Get-Value $conditions 'users'
                $groups = @(Get-List $users 'includeGroups')
                $frequency = Get-Value (Get-Value $_ 'sessionControls') 'signInFrequency'
                $groups.Count -and -not @(Get-List $users 'includeUsers').Count -and
                @(Get-List (Get-Value $_ 'grantControls') 'builtInControls') -contains 'mfa' -and
                $frequency -and (Get-Value $frequency 'isEnabled') -and
                @($groups | Where-Object { [string]$_ -in $travelIds }).Count -ge [math]::Ceiling($groups.Count / 2)
            })
        if ($sessions.Count -eq 1) {
            $frequency = Get-Value (Get-Value $sessions[0] 'sessionControls') 'signInFrequency'
            $hours = [int](Get-Value $frequency 'value') * $(if ((Get-Value $frequency 'type') -eq 'days') { 24 } else { 1 })
            $hours = [math]::Min([math]::Max($hours, 1), 720)
            $content.Travel | Add-Member -NotePropertyName ImportedSessionPolicyId -NotePropertyValue ([string](Get-Value $sessions[0] 'id')) -Force
            $content.Travel | Add-Member -NotePropertyName SessionHours -NotePropertyValue $hours -Force
            Add-Result Imported $sessions[0] 'SESSION' -Notes "Recorded for NSP-TRAVEL-SESSION-01 with SessionHours $hours. Adopting it scopes it to the active travel groups."
        } elseif ($sessions.Count -gt 1) {
            foreach ($session in $sessions) { Add-Result Skipped $session 'SESSION' -Notes 'Several policies look like the travel session policy; none was recorded.' }
        }
    }

    # Groups named like the imported ones that no travel policy uses.
    $names = @($imported | ForEach-Object { $_.GroupName })
    if ($names.Count -ge 2) {
        $prefix = $names[0]
        foreach ($name in $names) { while ($prefix -and -not $name.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { $prefix = $prefix.Substring(0, $prefix.Length - 1) } }
        if ($prefix.Length -ge 5) {
            foreach ($group in $groupById.Values) {
                $name = [string](Get-Value $group 'displayName')
                if ($name.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -and -not $usedGroups.ContainsKey([string](Get-Value $group 'id')) -and $name -notlike '*_azure') {
                    Add-Result Unused $null -Group $name -Notes 'Named like the travel groups, but no travel policy was imported for it. Add a travel entry, or delete the group.'
                }
            }
        }
    }

    if (@($results | Where-Object Result -eq 'Imported').Count -and $PSCmdlet.ShouldProcess($TenantProfile, "Import $(@($results | Where-Object Result -eq 'Imported').Count) travel polic(ies)")) {
        Save-NSPConditionalAccessJson -InputObject $content -Path $TenantProfile
    }
    $results.ToArray()
}