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