Skip to content

Commit f8cdec0

Browse files
authored
AI - 61301 - AI agent identities have assigned human owners and sponsors (#1471)
2 parents 79e32d6 + 4b73a8c commit f8cdec0

12 files changed

Lines changed: 871 additions & 12 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
Describe "Add-ZtAgentOwnershipDistribution" {
2+
BeforeAll {
3+
$here = $PSScriptRoot
4+
$srcRoot = Join-Path $here "../../src/powershell"
5+
6+
if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) {
7+
function global:Write-PSFMessage {
8+
param($Level, $Message, $Tag, $ErrorRecord)
9+
}
10+
}
11+
12+
if (-not (Get-Command Write-ZtProgress -ErrorAction SilentlyContinue)) {
13+
function global:Write-ZtProgress {
14+
param($Activity, $Status)
15+
}
16+
}
17+
18+
if (-not (Get-Command Add-ZtTenantInfo -ErrorAction SilentlyContinue)) {
19+
function global:Add-ZtTenantInfo {
20+
param($Name, $Value)
21+
}
22+
}
23+
24+
if (-not (Get-Command Invoke-DatabaseQuery -ErrorAction SilentlyContinue)) {
25+
function global:Invoke-DatabaseQuery {
26+
param($Database, $Sql)
27+
}
28+
}
29+
30+
if (-not (Get-Command Invoke-ZtGraphBatchRequest -ErrorAction SilentlyContinue)) {
31+
function global:Invoke-ZtGraphBatchRequest {
32+
[CmdletBinding()]
33+
param($Path, $ArgumentList, $Header, [switch] $NoPaging, [switch] $Matched)
34+
}
35+
}
36+
37+
function New-AgentOwnershipRow {
38+
param(
39+
[string] $Id,
40+
[string] $DisplayName,
41+
[int] $OwnerCount = 0,
42+
[object[]] $Sponsors = @(),
43+
[bool] $HasSponsorSnapshot = $true,
44+
[bool] $HasOwnerSnapshot = $true
45+
)
46+
47+
[pscustomobject]@{
48+
id = $Id
49+
displayName = $DisplayName
50+
accountEnabled = $true
51+
sponsorsJson = if ($Sponsors.Count -gt 0) { $Sponsors | ConvertTo-Json -Compress -Depth 5 } else { $null }
52+
hasSponsorSnapshot = $HasSponsorSnapshot
53+
hasOwnerSnapshot = $HasOwnerSnapshot
54+
ownerCount = $OwnerCount
55+
}
56+
}
57+
58+
. (Join-Path $srcRoot "private/tenantinfo/ai/Add-ZtAgentOwnershipDistribution.ps1")
59+
}
60+
61+
BeforeEach {
62+
$script:tenantInfo = $null
63+
$script:batchArguments = @()
64+
65+
Mock Write-PSFMessage {}
66+
Mock Write-ZtProgress {}
67+
Mock Add-ZtTenantInfo {
68+
param($Name, $Value)
69+
$script:tenantInfo = [pscustomobject]@{
70+
Name = $Name
71+
Value = $Value
72+
}
73+
}
74+
Mock Invoke-DatabaseQuery { @() }
75+
Mock Invoke-ZtGraphBatchRequest { @() }
76+
}
77+
78+
Context "Four-way classification" {
79+
It "Classifies each matched identity into exactly one bucket" {
80+
$userSponsor = [pscustomobject]@{ id = 'user-1'; '@odata.type' = '#microsoft.graph.user' }
81+
Mock Invoke-DatabaseQuery {
82+
@(
83+
New-AgentOwnershipRow -Id 'both' -DisplayName 'Both' -OwnerCount 1 -Sponsors @($userSponsor)
84+
New-AgentOwnershipRow -Id 'owner' -DisplayName 'Owner' -OwnerCount 1
85+
New-AgentOwnershipRow -Id 'sponsor' -DisplayName 'Sponsor' -Sponsors @($userSponsor)
86+
New-AgentOwnershipRow -Id 'neither' -DisplayName 'Neither'
87+
)
88+
}
89+
90+
Add-ZtAgentOwnershipDistribution -Database 'test'
91+
92+
$distribution = $script:tenantInfo.Value
93+
$distribution.ownerAndSponsor | Should -Be 1
94+
$distribution.ownerOnly | Should -Be 1
95+
$distribution.sponsorOnly | Should -Be 1
96+
$distribution.neither | Should -Be 1
97+
$distribution.skippedCount | Should -Be 0
98+
$allAgents = @($distribution.agents.ownerAndSponsor) + @($distribution.agents.ownerOnly) + @($distribution.agents.sponsorOnly) + @($distribution.agents.neither)
99+
$allAgents.Count | Should -Be 4
100+
@($allAgents.displayName | Sort-Object -Unique).Count | Should -Be 4
101+
@($distribution.agents.ownerAndSponsor).Count | Should -Be $distribution.ownerAndSponsor
102+
@($distribution.agents.ownerOnly).Count | Should -Be $distribution.ownerOnly
103+
@($distribution.agents.sponsorOnly).Count | Should -Be $distribution.sponsorOnly
104+
@($distribution.agents.neither).Count | Should -Be $distribution.neither
105+
}
106+
}
107+
108+
Context "Group sponsor resolution" {
109+
It "Uses successful counts and deduplicates repeated sponsor groups" {
110+
$populatedGroup = [pscustomobject]@{ id = 'group-1'; '@odata.type' = '#microsoft.graph.group' }
111+
$emptyGroup = [pscustomobject]@{ id = 'group-2'; '@odata.type' = '#microsoft.graph.group' }
112+
Mock Invoke-DatabaseQuery {
113+
@(
114+
New-AgentOwnershipRow -Id 'first' -DisplayName 'First' -Sponsors @($populatedGroup)
115+
New-AgentOwnershipRow -Id 'second' -DisplayName 'Second' -Sponsors @($populatedGroup)
116+
New-AgentOwnershipRow -Id 'third' -DisplayName 'Third' -Sponsors @($emptyGroup)
117+
)
118+
}
119+
Mock Invoke-ZtGraphBatchRequest {
120+
$script:batchArguments = @($ArgumentList)
121+
@(
122+
[pscustomobject]@{ Argument = 'group-1'; Success = $true; Result = @(3); Status = 200 }
123+
[pscustomobject]@{ Argument = 'group-2'; Success = $true; Result = @(0); Status = 200 }
124+
)
125+
}
126+
127+
Add-ZtAgentOwnershipDistribution -Database 'test'
128+
129+
$script:batchArguments.Count | Should -Be 2
130+
@($script:batchArguments | Sort-Object -Unique).Count | Should -Be 2
131+
$script:tenantInfo.Value.sponsorOnly | Should -Be 2
132+
$script:tenantInfo.Value.neither | Should -Be 1
133+
Should -Invoke Invoke-ZtGraphBatchRequest -Times 1 -Exactly
134+
}
135+
}
136+
137+
Context "Snapshot consistency" {
138+
It "Skips the symmetric difference without altering bucket counts" {
139+
Mock Invoke-DatabaseQuery {
140+
param($Database, $Sql)
141+
$script:query = $Sql
142+
@(
143+
New-AgentOwnershipRow -Id 'matched' -DisplayName 'Matched'
144+
New-AgentOwnershipRow -Id 'sponsor-only-snapshot' -DisplayName 'Sponsor snapshot only' -HasOwnerSnapshot $false
145+
New-AgentOwnershipRow -Id 'owner-only-snapshot' -DisplayName $null -HasSponsorSnapshot $false
146+
)
147+
}
148+
149+
Add-ZtAgentOwnershipDistribution -Database 'test'
150+
151+
$distribution = $script:tenantInfo.Value
152+
$distribution.neither | Should -Be 1
153+
$distribution.ownerAndSponsor + $distribution.ownerOnly + $distribution.sponsorOnly + $distribution.neither | Should -Be 1
154+
$distribution.skippedCount | Should -Be 2
155+
$distribution.neither + $distribution.skippedCount | Should -Be 3
156+
$script:query | Should -Match 'full outer join agent_owners'
157+
$script:query | Should -Match '"@odata.type" = ''#microsoft.graph.agentIdentity'''
158+
Should -Invoke Write-PSFMessage -Times 1 -Exactly -ParameterFilter {
159+
$Level -eq 'Warning' -and $Message -match '^2 agent identities were excluded'
160+
}
161+
}
162+
}
163+
164+
Context "Failure handling" {
165+
BeforeEach {
166+
$script:groupSponsor = [pscustomobject]@{ id = 'group-1'; '@odata.type' = '#microsoft.graph.group' }
167+
Mock Invoke-DatabaseQuery {
168+
@(New-AgentOwnershipRow -Id 'agent' -DisplayName 'Agent' -Sponsors @($script:groupSponsor))
169+
}
170+
}
171+
172+
It "Publishes null when the database query throws" {
173+
Mock Invoke-DatabaseQuery { throw 'database failed' }
174+
175+
Add-ZtAgentOwnershipDistribution -Database 'test'
176+
177+
$script:tenantInfo.Value | Should -BeNullOrEmpty
178+
Should -Invoke Invoke-ZtGraphBatchRequest -Times 0 -Exactly
179+
}
180+
181+
It "Publishes null when the batch request throws" {
182+
Mock Invoke-ZtGraphBatchRequest { throw 'batch failed' }
183+
184+
Add-ZtAgentOwnershipDistribution -Database 'test'
185+
186+
$script:tenantInfo.Value | Should -BeNullOrEmpty
187+
}
188+
189+
It "Publishes null for an unsuccessful batch result without using its argument" {
190+
Mock Invoke-ZtGraphBatchRequest {
191+
[pscustomobject]@{
192+
Argument = @{ url = 'groups/group-1/transitiveMembers/$count' }
193+
Success = $false
194+
Result = $null
195+
Status = 503
196+
}
197+
}
198+
199+
Add-ZtAgentOwnershipDistribution -Database 'test'
200+
201+
$script:tenantInfo.Value | Should -BeNullOrEmpty
202+
}
203+
204+
It "Publishes null when a requested group result is omitted" {
205+
$secondGroup = [pscustomobject]@{ id = 'group-2'; '@odata.type' = '#microsoft.graph.group' }
206+
Mock Invoke-DatabaseQuery {
207+
@(
208+
New-AgentOwnershipRow -Id 'first' -DisplayName 'First' -Sponsors @($script:groupSponsor)
209+
New-AgentOwnershipRow -Id 'second' -DisplayName 'Second' -Sponsors @($secondGroup)
210+
)
211+
}
212+
Mock Invoke-ZtGraphBatchRequest {
213+
[pscustomobject]@{ Argument = 'group-1'; Success = $true; Result = @(1); Status = 200 }
214+
}
215+
216+
Add-ZtAgentOwnershipDistribution -Database 'test'
217+
218+
$script:tenantInfo.Value | Should -BeNullOrEmpty
219+
}
220+
}
221+
222+
Context "Empty tenant" {
223+
It "Publishes zero counts and empty detail arrays" {
224+
Add-ZtAgentOwnershipDistribution -Database 'test'
225+
226+
$distribution = $script:tenantInfo.Value
227+
$distribution.ownerAndSponsor | Should -Be 0
228+
$distribution.ownerOnly | Should -Be 0
229+
$distribution.sponsorOnly | Should -Be 0
230+
$distribution.neither | Should -Be 0
231+
$distribution.skippedCount | Should -Be 0
232+
@($distribution.agents.ownerAndSponsor).Count | Should -Be 0
233+
@($distribution.agents.ownerOnly).Count | Should -Be 0
234+
@($distribution.agents.sponsorOnly).Count | Should -Be 0
235+
@($distribution.agents.neither).Count | Should -Be 0
236+
Should -Invoke Invoke-ZtGraphBatchRequest -Times 0 -Exactly
237+
}
238+
}
239+
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ Describe "Invoke-ZtTenantInfo" {
1616
}
1717
}
1818

19+
if (-not (Get-Command Add-ZtAgentOwnershipDistribution -ErrorAction SilentlyContinue)) {
20+
function global:Add-ZtAgentOwnershipDistribution {
21+
param($Database)
22+
}
23+
}
24+
1925
if (-not (Get-Command Add-ZtOverviewCaMfa -ErrorAction SilentlyContinue)) {
2026
function global:Add-ZtOverviewCaMfa {
2127
param($Database)
@@ -82,6 +88,7 @@ Describe "Invoke-ZtTenantInfo" {
8288
BeforeEach {
8389
Mock Add-ZtTenantOverview {}
8490
Mock Add-ZtAgentOverview {}
91+
Mock Add-ZtAgentOwnershipDistribution {}
8592
Mock Add-ZtOverviewCaMfa {}
8693
Mock Add-ZtOverviewCaDevicesAllUsers {}
8794
Mock Add-ZtOverviewAuthMethodsAllUsers {}
@@ -103,4 +110,18 @@ Describe "Invoke-ZtTenantInfo" {
103110
Should -Invoke Add-ZTDeviceCompliancePolicies -Times 0 -Exactly
104111
Should -Invoke Add-ZTDeviceAppProtectionPolicies -Times 0 -Exactly
105112
}
113+
114+
It "Should collect agent ownership distribution for AI assessments" {
115+
Invoke-ZtTenantInfo -Database 'test' -Pillar 'AI'
116+
117+
Should -Invoke Add-ZtAgentOwnershipDistribution -Times 1 -Exactly -ParameterFilter {
118+
$Database -eq 'test'
119+
}
120+
}
121+
122+
It "Should not collect agent ownership distribution for unrelated pillar-only assessments" {
123+
Invoke-ZtTenantInfo -Database 'test' -Pillar 'Devices'
124+
125+
Should -Invoke Add-ZtAgentOwnershipDistribution -Times 0 -Exactly
126+
}
106127
}

src/powershell/assets/ReportTemplate.classic.html

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

src/powershell/assets/ReportTemplate.html

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

src/powershell/private/tenantinfo/Invoke-ZtTenantInfo.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ function Invoke-ZtTenantInfo {
1818
Add-ZtTenantOverview # Always run (shown on dashboard)
1919
Add-ZtAgentOverview # Always run (shown on dashboard)
2020

21+
if ($Pillar -in ('All', 'AI')) {
22+
Add-ZtAgentOwnershipDistribution -Database $Database
23+
}
24+
2125
# Only run if Pillar is All or Identity
2226
if ($Pillar -in ('All', 'Identity')) {
2327
Add-ZtOverviewCaMfa -Database $Database

0 commit comments

Comments
 (0)