Public/New-NSPConditionalAccessTenantProfile.ps1

function New-NSPConditionalAccessTenantProfile {
    <#
    .SYNOPSIS
        Builds and saves a tenant profile (breakglass, service accounts, group slots, country locations) from an inventory.
    .DESCRIPTION
        Works offline from an inventory: the object from Get-NSPConditionalAccessInventory, or the
        path of a saved snapshot. Writes Config\Local\TenantProfiles\<TenantId>.json (Git-ignored).
 
        Decision rules:
          - Breakglass is never chosen automatically. Pass -BreakGlassId, or pick in -Interactive.
          - Service accounts default to the detected directory-sync accounts (a role fact, not a
            guess). -ServiceAccountId overrides; an empty array records "reviewed, none".
          - Group slots and country locations take an explicit -GroupId / -CountryLocationId,
            an interactive choice, or, with -AcceptProposals, an unambiguous proposal. An
            ambiguous slot is never guessed. A slot with no match is recorded as Missing, with
            the name to create it under, for the planner.
          - An existing profile is not overwritten without -Force. A forced rebuild keeps travel
            entries, coverage settings, tenant options, authentication strength bindings, users
            and groups added to policies, and country locations it did not rebind.
 
        -Interactive prompts through NSP.Console, loaded like NSP.M365.Graph.
    .EXAMPLE
        New-NSPConditionalAccessTenantProfile -Inventory $inventory -Interactive
    .EXAMPLE
        New-NSPConditionalAccessTenantProfile -Inventory .\.nsp-conditionalaccess\inventory\<tenant>\<stamp>.json `
            -BreakGlassId $id -AcceptProposals
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Inventory,
        [string[]]$BreakGlassId,
        [AllowEmptyCollection()][string[]]$ServiceAccountId,
        [hashtable]$GroupId = @{},
        [hashtable]$CountryLocationId = @{},
        [switch]$AcceptProposals,
        [switch]$Interactive,
        [switch]$Force,
        [string]$ConfigRoot
    )

    if ($Inventory -is [string]) {
        $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json
    }
    $proposal = Get-NSPConditionalAccessProfileProposal -Inventory $Inventory
    if (-not $proposal.TenantId) { throw 'The inventory has no TenantId.' }
    if ($Interactive) {
        Import-NSPToolkitModule -Name 'NSP.Console' -MinimumVersion '0.1.1'
        Write-NSPConsoleLine "Tenant profile for $($proposal.TenantDomain) ($($proposal.TenantId))" -Role Heading
    }

    $profilePath = Join-Path (Get-NSPConditionalAccessLocalPath -Kind TenantProfiles -ConfigRoot $ConfigRoot) "$($proposal.TenantId).json"
    if ((Test-Path -LiteralPath $profilePath) -and -not $Force) {
        throw "A tenant profile already exists at $profilePath. Use -Force to replace it."
    }

    $accountsById = @{}
    foreach ($account in $proposal.Accounts) { $accountsById[$account.Id] = $account }
    function Get-AccountLabel {
        param([string]$Id)
        $account = $accountsById[$Id]
        if ($account) { if ($account.UserPrincipalName) { $account.UserPrincipalName } else { $account.DisplayName } } else { "(not in inventory) $Id" }
    }

    # Breakglass and service accounts
    $breakGlass = [System.Collections.Generic.List[string]]::new()
    $service = [System.Collections.Generic.List[string]]::new()
    if ($Interactive -and -not $PSBoundParameters.ContainsKey('BreakGlassId')) {
        Write-NSPConsoleLine 'Accounts excluded by existing policies, sync accounts, and breakglass-named users:' -Role Heading
        foreach ($account in $proposal.Accounts) {
            $detail = "$(Get-AccountLabel $account.Id) - excluded by $($account.ExcludedByCount) policies, $(if ($account.Synced) { 'synced' } else { 'cloud-only' }), suggested: $($account.SuggestedCategory)"
            Write-NSPConsoleLine $detail -Role Normal
            $choices = @(
                [pscustomobject]@{ Label = 'Breakglass (emergency access; excluded from every policy)'; Value = 'BreakGlass' }
                [pscustomobject]@{ Label = 'Service account (must stay excluded, e.g. directory sync)'; Value = 'ServiceAccount' }
                [pscustomobject]@{ Label = 'Neither'; Value = 'Neither' }
            )
            switch (Read-NSPChoice -Prompt "Category for $($account.DisplayName)" -Choices $choices) {
                'BreakGlass' { $breakGlass.Add($account.Id) }
                'ServiceAccount' { $service.Add($account.Id) }
                $null { throw 'Tenant profile cancelled.' }
            }
        }
        $ServiceAccountId = $service.ToArray()
    } else {
        foreach ($id in @($BreakGlassId | Where-Object { $_ })) { $breakGlass.Add($id) }
    }
    if ($breakGlass.Count -eq 0) {
        $candidates = @($proposal.Accounts | ForEach-Object { "$(Get-AccountLabel $_.Id) [$($_.Id)] suggested $($_.SuggestedCategory)" })
        throw "No breakglass account chosen. Pass -BreakGlassId or use -Interactive. Candidates: $(if ($candidates) { $candidates -join '; ' } else { 'none found' })"
    }
    if (-not $PSBoundParameters.ContainsKey('ServiceAccountId') -and -not $Interactive) {
        $ServiceAccountId = @($proposal.Accounts | Where-Object SuggestedCategory -eq 'ServiceAccount' | ForEach-Object { $_.Id })
    }
    $ServiceAccountId = @($ServiceAccountId | Where-Object { $_ -and $_ -notin $breakGlass })

    # Group slots
    $groupsOut = [ordered]@{}
    foreach ($slot in $proposal.GroupSlots) {
        $ids = $null
        if ($GroupId.ContainsKey($slot.Slot)) {
            $ids = @($GroupId[$slot.Slot] | Where-Object { $_ })
        } elseif ($Interactive) {
            $choices = @($slot.Candidates | ForEach-Object {
                    [pscustomobject]@{ Label = "$($_.DisplayName) ($(if ($_.Synced) { 'synced' } else { 'cloud-only' }))"; Value = $_ }
                }) + [pscustomobject]@{ Label = "None - plan creation of $($slot.DefaultName)"; Value = 'None' }
            $picked = Read-NSPChoice -Prompt "Group for slot $($slot.Slot)" -Choices $choices
            if ($null -eq $picked) { throw 'Tenant profile cancelled.' }
            $ids = if ($picked -eq 'None') { @() } else {
                @($picked.Id) + @(if ($picked.Companion -and (Read-NSPConfirm -Prompt "Also target the cloud-only companion $($picked.Companion.DisplayName)?" -DefaultYes)) { $picked.Companion.Id })
            }
        } elseif ($AcceptProposals -and $slot.Proposal) {
            $ids = @($slot.Proposal.Id) + @(if ($slot.Proposal.Companion) { $slot.Proposal.Companion.Id })
        } elseif ($slot.Status -eq 'Missing') {
            $ids = @()
        } else {
            throw "Group slot $($slot.Slot) is $($slot.Status). Pass -GroupId @{ $($slot.Slot) = '<id>' }, -AcceptProposals, or use -Interactive."
        }
        $labels = @($ids | ForEach-Object {
                $id = $_
                $match = @($slot.Candidates | ForEach-Object { $_; $_.Companion } | Where-Object { $_ -and $_.Id -eq $id })[0]
                if ($match) { $match.DisplayName } else { "(not in inventory) $id" }
            })
        $groupsOut[$slot.Slot] = [ordered]@{
            Ids = @($ids)
            Labels = $labels
            Missing = ($ids.Count -eq 0)
            DefaultName = $slot.DefaultName
        }
    }

    # Countries
    $countriesOut = [ordered]@{}
    $missingCountries = [System.Collections.Generic.List[string]]::new()
    foreach ($country in $proposal.Countries) {
        $id = $null
        if ($CountryLocationId.ContainsKey($country.Country)) {
            $id = $CountryLocationId[$country.Country]
        } elseif ($Interactive -and $country.Candidates.Count -gt 0) {
            $choices = @($country.Candidates | ForEach-Object {
                    [pscustomobject]@{ Label = "$($_.DisplayName) [$($_.Id)] referenced by $($_.ReferencedBy) policies, created $($_.Created)"; Value = $_.Id }
                }) + [pscustomobject]@{ Label = 'None - plan creation'; Value = 'None' }
            $id = Read-NSPChoice -Prompt "Named location for country $($country.Country)" -Choices $choices
            if ($null -eq $id) { throw 'Tenant profile cancelled.' }
            if ($id -eq 'None') { $id = $null }
        } elseif ($AcceptProposals -and $country.Proposal) {
            $id = $country.Proposal.Id
        } elseif ($country.Status -ne 'Missing') {
            throw "Country $($country.Country) has $($country.Status.ToLowerInvariant()) named locations. Pass -CountryLocationId @{ $($country.Country) = '<id>' }, -AcceptProposals, or use -Interactive."
        }
        if ($id) { $countriesOut[$country.Country] = $id } else { $missingCountries.Add($country.Country) }
    }

    $registration = Join-Path (Get-NSPConditionalAccessLocalPath -Kind AppRegistrations -ConfigRoot $ConfigRoot) "$($proposal.TenantId).json"
    $clientId = if (Test-Path -LiteralPath $registration) { (Get-Content -LiteralPath $registration -Raw | ConvertFrom-Json).ClientId } else { $null }

    $tenantProfile = [ordered]@{
        SchemaVersion = 1
        TenantId = $proposal.TenantId
        TenantDomain = $proposal.TenantDomain
        ClientId = $clientId
        Hybrid = $proposal.Hybrid
        HomeCountry = $proposal.HomeCountry
        CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        SourceInventoryCapturedAtUtc = $proposal.InventoryCapturedAtUtc
        BreakGlass = [ordered]@{ Ids = $breakGlass.ToArray(); Labels = @($breakGlass | ForEach-Object { Get-AccountLabel $_ }) }
        ServiceAccounts = [ordered]@{ Ids = @($ServiceAccountId); Labels = @($ServiceAccountId | ForEach-Object { Get-AccountLabel $_ }) }
        Groups = $groupsOut
        NamedLocations = [ordered]@{ Countries = $countriesOut; Named = [ordered]@{} }
        MissingCountries = $missingCountries.ToArray()
    }

    # A rebuild replaces what comes from the inventory and keeps what the operator set up since:
    # travel entries, coverage settings, tenant options, authentication strength bindings, users
    # and groups added to policies, and country locations bound for travel.
    if (Test-Path -LiteralPath $profilePath) {
        $previous = Get-Content -LiteralPath $profilePath -Raw | ConvertFrom-Json
        foreach ($section in 'Travel', 'Coverage', 'Options', 'AuthenticationStrengths', 'PolicyTargets') {
            if ($previous.PSObject.Properties[$section] -and $null -ne $previous.$section) { $tenantProfile[$section] = $previous.$section }
        }
        if ($previous.NamedLocations -and $previous.NamedLocations.Countries) {
            foreach ($country in $previous.NamedLocations.Countries.PSObject.Properties) {
                if (-not $countriesOut.Contains($country.Name)) { $countriesOut[$country.Name] = $country.Value }
            }
        }
    }

    if ($Interactive) {
        Write-NSPConsoleLine "Breakglass: $($tenantProfile.BreakGlass.Labels -join ', ')" -Role Success
        Write-NSPConsoleLine "Service accounts: $(if ($ServiceAccountId) { $tenantProfile.ServiceAccounts.Labels -join ', ' } else { '(none)' })"
        foreach ($slot in $groupsOut.Keys) {
            $entry = $groupsOut[$slot]
            Write-NSPConsoleLine "Group $slot : $(if ($entry.Missing) { "MISSING - planner will propose creating $($entry.DefaultName)" } else { $entry.Labels -join ' + ' })" -Role $(if ($entry.Missing) { 'Warning' } else { 'Normal' })
        }
        foreach ($iso in $missingCountries) { Write-NSPConsoleLine "Country $iso : MISSING - planner will propose a named location" -Role Warning }
        if (-not (Read-NSPConfirm -Prompt "Save the tenant profile to $profilePath?" -DefaultYes)) { throw 'Tenant profile not saved.' }
    }

    if ($PSCmdlet.ShouldProcess($profilePath, 'Save tenant profile')) {
        New-Item -ItemType Directory -Path (Split-Path -Parent $profilePath) -Force | Out-Null
        ConvertTo-Json -InputObject $tenantProfile -Depth 10 | Set-Content -LiteralPath $profilePath -Encoding UTF8
    }
    [pscustomobject]@{ Path = $profilePath; Profile = [pscustomobject]$tenantProfile; StaleExclusions = $proposal.StaleExclusions }
}