Skip to content

Commit 43d04c5

Browse files
authored
Network - 27021 - Private Access applications are governed by least-privilege segmentation, strong authentication, and constrained administration (#1476)
2 parents f8cdec0 + 7c401e0 commit 43d04c5

20 files changed

Lines changed: 918 additions & 4 deletions
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
Describe "Add-ZtOverviewPrivateAccess" {
2+
3+
BeforeAll {
4+
$here = $PSScriptRoot
5+
$srcRoot = Join-Path $here "../../src/powershell"
6+
7+
if (-not (Get-Command Get-ZtTestData -ErrorAction SilentlyContinue)) {
8+
function global:Get-ZtTestData {
9+
param([string]$Name)
10+
}
11+
}
12+
13+
if (-not (Get-Command Get-ZtTestResultStatus -ErrorAction SilentlyContinue)) {
14+
function global:Get-ZtTestResultStatus {
15+
param([string]$TestId)
16+
}
17+
}
18+
19+
if (-not (Get-Command Add-ZtTenantInfo -ErrorAction SilentlyContinue)) {
20+
function global:Add-ZtTenantInfo {
21+
param([string]$Name, $Value)
22+
}
23+
}
24+
25+
if (-not (Get-Command Write-ZtProgress -ErrorAction SilentlyContinue)) {
26+
function global:Write-ZtProgress {
27+
param([string]$Activity, [string]$Status)
28+
}
29+
}
30+
31+
if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) {
32+
function global:Write-PSFMessage {
33+
param([Parameter(ValueFromRemainingArguments = $true)]$Args)
34+
}
35+
}
36+
37+
function global:New-ZtAppRow {
38+
param([string]$AppId, [string]$Status)
39+
[PSCustomObject]@{ AppId = $AppId; Status = $Status }
40+
}
41+
42+
function global:Get-ZtSankeyValue {
43+
param($Nodes, [string]$Source, [string]$Target)
44+
($Nodes | Where-Object { $_.source -eq $Source -and $_.target -eq $Target }).value
45+
}
46+
47+
. (Join-Path $srcRoot "private/tenantinfo/Add-ZtOverviewPrivateAccess.ps1")
48+
}
49+
50+
BeforeEach {
51+
$script:tenantInfo = 'not-set'
52+
53+
Mock Write-ZtProgress {}
54+
Mock Write-PSFMessage {}
55+
Mock Add-ZtTenantInfo { $script:tenantInfo = $Value }
56+
Mock Get-ZtTestData { @() }
57+
Mock Get-ZtTestResultStatus { 'Passed' }
58+
}
59+
60+
Context "Application partitioning" {
61+
62+
It "Partitions apps into pass, fail and manual review" {
63+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
64+
@(
65+
(New-ZtAppRow -AppId 'a' -Status 'Pass'),
66+
(New-ZtAppRow -AppId 'b' -Status 'Fail'),
67+
(New-ZtAppRow -AppId 'c' -Status 'ManualReview')
68+
)
69+
}
70+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
71+
@(
72+
(New-ZtAppRow -AppId 'a' -Status 'Pass'),
73+
(New-ZtAppRow -AppId 'b' -Status 'Pass'),
74+
(New-ZtAppRow -AppId 'c' -Status 'Pass')
75+
)
76+
}
77+
78+
Add-ZtOverviewPrivateAccess
79+
80+
$script:tenantInfo.applicationCount | Should -Be 3
81+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Private Access apps' 'Broad segments - at-risk' | Should -Be 1
82+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Private Access apps' 'Segmentation manual review' | Should -Be 1
83+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Private Access apps' 'Least-privilege segments' | Should -Be 1
84+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Strong auth - Zero Trust' | Should -Be 1
85+
$script:tenantInfo.populationMismatch | Should -BeFalse
86+
}
87+
88+
It "Joins the gates on App ID rather than on row order" {
89+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
90+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'b' -Status 'Pass'))
91+
}
92+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
93+
@((New-ZtAppRow -AppId 'b' -Status 'Fail'), (New-ZtAppRow -AppId 'a' -Status 'Pass'))
94+
}
95+
96+
Add-ZtOverviewPrivateAccess
97+
98+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Strong auth - Zero Trust' | Should -Be 1
99+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Password-only - at-risk' | Should -Be 1
100+
}
101+
102+
It "Collapses duplicate and case-variant App IDs into one application" {
103+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
104+
@(
105+
(New-ZtAppRow -AppId 'AAA' -Status 'Pass'),
106+
(New-ZtAppRow -AppId 'aaa' -Status 'Pass'),
107+
(New-ZtAppRow -AppId ' aaa ' -Status 'Pass')
108+
)
109+
}
110+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
111+
@((New-ZtAppRow -AppId 'aAa' -Status 'Pass'))
112+
}
113+
114+
Add-ZtOverviewPrivateAccess
115+
116+
$script:tenantInfo.applicationCount | Should -Be 1
117+
$script:tenantInfo.populationMismatch | Should -BeFalse
118+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Strong auth - Zero Trust' | Should -Be 1
119+
}
120+
}
121+
122+
Context "Population mismatch" {
123+
124+
It "Flags a mismatch when the gates evaluated different app counts" {
125+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
126+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'b' -Status 'Pass'))
127+
}
128+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
129+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'))
130+
}
131+
132+
Add-ZtOverviewPrivateAccess
133+
134+
$script:tenantInfo.populationMismatch | Should -BeTrue
135+
$script:tenantInfo.overallStatus | Should -Be 'Investigate'
136+
}
137+
138+
It "Flags a mismatch when the gates evaluated different apps but equal counts" {
139+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
140+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'b' -Status 'Pass'))
141+
}
142+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
143+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'c' -Status 'Pass'))
144+
}
145+
146+
Add-ZtOverviewPrivateAccess
147+
148+
$script:tenantInfo.populationMismatch | Should -BeTrue
149+
$script:tenantInfo.overallStatus | Should -Be 'Investigate'
150+
}
151+
152+
It "Keeps authentication-only Quick Access apps in the population" {
153+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
154+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'))
155+
}
156+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAuthentication' } -MockWith {
157+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'quickaccess' -Status 'Fail'))
158+
}
159+
160+
Add-ZtOverviewPrivateAccess
161+
162+
$script:tenantInfo.applicationCount | Should -Be 2
163+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Private Access apps' 'Segmentation unavailable' | Should -Be 1
164+
}
165+
}
166+
167+
Context "Administration band" {
168+
169+
It "Reports scoped assignments that fail their principal checks as at-risk" {
170+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAdministration' } -MockWith {
171+
[PSCustomObject]@{ TenantWide = 0; Scoped = 5; ScopedAtRisk = 2 }
172+
}
173+
174+
Add-ZtOverviewPrivateAccess
175+
176+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Application Administrator assignments' 'App-scoped admin - at-risk' | Should -Be 2
177+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Application Administrator assignments' 'App-scoped admin - Zero Trust' | Should -Be 3
178+
$script:tenantInfo.adminAtRisk | Should -BeTrue
179+
}
180+
181+
It "Does not report admin risk when every scoped assignment is clean" {
182+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessAdministration' } -MockWith {
183+
[PSCustomObject]@{ TenantWide = 0; Scoped = 4; ScopedAtRisk = 0 }
184+
}
185+
186+
Add-ZtOverviewPrivateAccess
187+
188+
$script:tenantInfo.adminAtRisk | Should -BeFalse
189+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Application Administrator assignments' 'App-scoped admin - Zero Trust' | Should -Be 4
190+
}
191+
192+
It "Reports an unavailable flow when the administration child produced no result" {
193+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25384' } -MockWith { $null }
194+
195+
Add-ZtOverviewPrivateAccess
196+
197+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Application Administrator assignments' 'Administration unavailable' | Should -Be 1
198+
}
199+
}
200+
201+
Context "Child verdict propagation" {
202+
203+
It "Reports Passed when every child passed" {
204+
Add-ZtOverviewPrivateAccess
205+
206+
$script:tenantInfo.overallStatus | Should -Be 'Passed'
207+
$script:tenantInfo.degraded | Should -BeFalse
208+
}
209+
210+
It "Reports Failed when any child failed" {
211+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25396' } -MockWith { 'Failed' }
212+
213+
Add-ZtOverviewPrivateAccess
214+
215+
$script:tenantInfo.overallStatus | Should -Be 'Failed'
216+
($script:tenantInfo.gates | Where-Object { $_.testId -eq '25396' }).status | Should -Be 'Failed'
217+
}
218+
219+
It "Reports Investigate when a child needs manual review" {
220+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25395' } -MockWith { 'Investigate' }
221+
222+
Add-ZtOverviewPrivateAccess
223+
224+
$script:tenantInfo.overallStatus | Should -Be 'Investigate'
225+
}
226+
227+
It "Does not report Passed when a child was skipped" {
228+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25396' } -MockWith { 'Skipped' }
229+
230+
Add-ZtOverviewPrivateAccess
231+
232+
$script:tenantInfo.overallStatus | Should -Be 'Investigate'
233+
$script:tenantInfo.degraded | Should -BeTrue
234+
($script:tenantInfo.gates | Where-Object { $_.testId -eq '25396' }).status | Should -Be 'Unavailable'
235+
$script:tenantInfo.description | Should -BeLike '*Strong authentication*'
236+
}
237+
238+
It "Treats errored children as unavailable" {
239+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25384' } -MockWith { 'Error' }
240+
241+
Add-ZtOverviewPrivateAccess
242+
243+
($script:tenantInfo.gates | Where-Object { $_.testId -eq '25384' }).status | Should -Be 'Unavailable'
244+
$script:tenantInfo.degraded | Should -BeTrue
245+
}
246+
247+
It "Treats children that never ran as unavailable" {
248+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25395' } -MockWith { $null }
249+
250+
Add-ZtOverviewPrivateAccess
251+
252+
($script:tenantInfo.gates | Where-Object { $_.testId -eq '25395' }).status | Should -Be 'Unavailable'
253+
$script:tenantInfo.degraded | Should -BeTrue
254+
}
255+
}
256+
257+
Context "Selective execution" {
258+
259+
It "Reports unmatched apps as unavailable when only segmentation ran" {
260+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25396' } -MockWith { $null }
261+
Mock Get-ZtTestResultStatus -ParameterFilter { $TestId -eq '25384' } -MockWith { $null }
262+
Mock Get-ZtTestData -ParameterFilter { $Name -eq 'PrivateAccessSegmentation' } -MockWith {
263+
@((New-ZtAppRow -AppId 'a' -Status 'Pass'), (New-ZtAppRow -AppId 'b' -Status 'Pass'))
264+
}
265+
266+
Add-ZtOverviewPrivateAccess
267+
268+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Authentication unavailable' | Should -Be 2
269+
Get-ZtSankeyValue $script:tenantInfo.nodes 'Least-privilege segments' 'Authentication manual review' | Should -Be 0
270+
$script:tenantInfo.populationMismatch | Should -BeFalse
271+
}
272+
273+
It "Publishes nothing when no child produced a result" {
274+
Mock Get-ZtTestResultStatus { $null }
275+
276+
Add-ZtOverviewPrivateAccess
277+
278+
Should -Invoke Add-ZtTenantInfo -Times 1 -Exactly
279+
$script:tenantInfo | Should -BeNullOrEmpty
280+
}
281+
}
282+
}

code-tests/commands/Invoke-ZtTenantInfo.Tests.ps1

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ Describe "Invoke-ZtTenantInfo" {
8282
}
8383
}
8484

85+
if (-not (Get-Command Add-ZtOverviewPrivateAccess -ErrorAction SilentlyContinue)) {
86+
function global:Add-ZtOverviewPrivateAccess {
87+
param()
88+
}
89+
}
90+
8591
. (Join-Path $srcRoot "private/tenantinfo/Invoke-ZtTenantInfo.ps1")
8692
}
8793

@@ -99,6 +105,7 @@ Describe "Invoke-ZtTenantInfo" {
99105
Mock Add-ZtDeviceEnrollmentRestriction {}
100106
Mock Add-ZTDeviceCompliancePolicies {}
101107
Mock Add-ZTDeviceAppProtectionPolicies {}
108+
Mock Add-ZtOverviewPrivateAccess {}
102109
}
103110

104111
It "Should call Add-ZtDeviceOverview even when Intune is unavailable" {
@@ -111,6 +118,24 @@ Describe "Invoke-ZtTenantInfo" {
111118
Should -Invoke Add-ZTDeviceAppProtectionPolicies -Times 0 -Exactly
112119
}
113120

121+
It "Should build the Private Access overview for the Network pillar" {
122+
Invoke-ZtTenantInfo -Database 'test' -Pillar 'Network'
123+
124+
Should -Invoke Add-ZtOverviewPrivateAccess -Times 1 -Exactly
125+
}
126+
127+
It "Should build the Private Access overview for the All pillar" {
128+
Invoke-ZtTenantInfo -Database 'test' -Pillar 'All'
129+
130+
Should -Invoke Add-ZtOverviewPrivateAccess -Times 1 -Exactly
131+
}
132+
133+
It "Should not build the Private Access overview for unrelated pillars" {
134+
Invoke-ZtTenantInfo -Database 'test' -Pillar 'Devices'
135+
136+
Should -Invoke Add-ZtOverviewPrivateAccess -Times 0 -Exactly
137+
}
138+
114139
It "Should collect agent ownership distribution for AI assessments" {
115140
Invoke-ZtTenantInfo -Database 'test' -Pillar 'AI'
116141

src/powershell/assets/ReportTemplate.classic.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/powershell/assets/ReportTemplate.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<#
2+
.SYNOPSIS
3+
Publish structured data from a check so that composite dashboards can consume it.
4+
5+
.DESCRIPTION
6+
Roll-up test results only carry a Pass/Fail verdict. Dashboards that aggregate several
7+
checks (for example the Private Access funnel) need the per-item rows a check already
8+
computed. This stores those rows in threadsafe module state so they survive the parallel
9+
test runspaces and can be read during the tenant information stage.
10+
11+
.EXAMPLE
12+
Add-ZtTestData -Name 'PrivateAccessSegmentation' -Value $appResults
13+
14+
Publishes the per-application segmentation rows for later aggregation.
15+
#>
16+
17+
function Add-ZtTestData {
18+
[CmdletBinding()]
19+
param(
20+
# The unique name for this data set.
21+
[Parameter(Mandatory = $true)]
22+
[string] $Name,
23+
24+
# The value to publish.
25+
$Value
26+
)
27+
28+
$script:__ZtSession.TestData.Value[$Name] = $Value
29+
}

src/powershell/private/core/Clear-ZtModuleVariable.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function Clear-ZtModuleVariable {
1818
$script:__ZtSession.AzureCache.Value.Clear()
1919
$script:__ZtSession.GraphBaseUri = $null
2020
$script:__ZtSession.TestResultDetail.Value.Clear()
21+
$script:__ZtSession.TestData.Value.Clear()
2122
$script:__ZtSession.TestStatistics.Value.Clear()
2223
$script:__ZtSession.TenantInfo.Value.Clear()
2324
$script:__ZtSession.ProgressState.Value.Clear()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<#
2+
.SYNOPSIS
3+
Read structured data published by a check via Add-ZtTestData.
4+
5+
.DESCRIPTION
6+
Returns $null when the check did not run, was skipped, or published nothing.
7+
8+
.EXAMPLE
9+
Get-ZtTestData -Name 'PrivateAccessSegmentation'
10+
#>
11+
12+
function Get-ZtTestData {
13+
[CmdletBinding()]
14+
param(
15+
# The unique name of the data set to read.
16+
[Parameter(Mandatory = $true)]
17+
[string] $Name
18+
)
19+
20+
$script:__ZtSession.TestData.Value[$Name]
21+
}

0 commit comments

Comments
 (0)