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 } } |