Skip to content

Commit 570b98c

Browse files
committed
feat(alerts): add ExecResolveAlert endpoint
Resolve differs from snooze: it removes a fired alert item immediately but does not suppress it, so a condition that still exists legitimately re-fires on the next scheduled run. The item is removed by content hash from every AlertLastRun partition so older rows cannot resurface it, and rows that empty out are deleted. The rewrite preserves the LastSeen stamp so the scheduler does not misread surviving items as stale, and caller input is escaped via ConvertTo-CIPPODataFilterValue before filter interpolation.
1 parent 4139ef5 commit 570b98c

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
function Invoke-ExecResolveAlert {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint,AnyTenant
5+
.ROLE
6+
CIPP.Alert.ReadWrite
7+
.DESCRIPTION
8+
Marks a fired alert item as resolved by removing it from the stored AlertLastRun
9+
state, so it disappears from the dashboard immediately. Unlike a snooze this does
10+
not suppress the alert: if the underlying condition still exists, the item fires
11+
again on the alert's next scheduled run.
12+
#>
13+
[CmdletBinding()]
14+
param($Request, $TriggerMetadata)
15+
16+
$APIName = $Request.Params.CIPPEndpoint
17+
$Headers = $Request.Headers
18+
19+
try {
20+
$CmdletName = $Request.Body.CmdletName
21+
$TenantFilter = $Request.Body.TenantFilter
22+
$AlertItem = $Request.Body.AlertItem
23+
24+
if ([string]::IsNullOrWhiteSpace($CmdletName) -or [string]::IsNullOrWhiteSpace($TenantFilter) -or $null -eq $AlertItem) {
25+
return ([HttpResponseContext]@{
26+
StatusCode = [HttpStatusCode]::BadRequest
27+
Body = @{ Results = 'CmdletName, TenantFilter, and AlertItem are required.' }
28+
})
29+
}
30+
31+
$HashResult = Get-AlertContentHash -AlertItem $AlertItem
32+
33+
$Table = Get-CIPPTable -tablename 'AlertLastRun'
34+
# Remove the item from every partition for this alert, not just the latest:
35+
# if the latest row is deleted, ListAlertResults falls back to an older-dated
36+
# row, which would resurface the item. TenantFilter/CmdletName are caller
37+
# input - escape them so a quote cannot widen the filter to foreign rows.
38+
$SafeRowKey = ConvertTo-CIPPODataFilterValue -Value "$($TenantFilter)-$($CmdletName)" -Type String
39+
$Rows = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$SafeRowKey'"
40+
41+
$Removed = 0
42+
foreach ($Row in @($Rows)) {
43+
if ([string]::IsNullOrWhiteSpace($Row.LogData)) { continue }
44+
try {
45+
$Items = @($Row.LogData | ConvertFrom-Json -ErrorAction Stop)
46+
} catch {
47+
continue
48+
}
49+
50+
$Keep = @($Items | Where-Object { (Get-AlertContentHash -AlertItem $_).ContentHash -ne $HashResult.ContentHash })
51+
if ($Keep.Count -eq $Items.Count) { continue }
52+
$Removed += $Items.Count - $Keep.Count
53+
54+
if ($Keep.Count -eq 0) {
55+
Remove-AzDataTableEntity -Force @Table -Entity $Row | Out-Null
56+
} else {
57+
$LogData = ConvertTo-Json -InputObject $Keep -Compress -Depth 10 | Out-String
58+
$Entity = @{
59+
'PartitionKey' = $Row.PartitionKey
60+
'RowKey' = $Row.RowKey
61+
'CmdletName' = "$($Row.CmdletName)"
62+
'Tenant' = "$($Row.Tenant)"
63+
'LogData' = [string]$LogData
64+
'AlertComment' = [string]$Row.AlertComment
65+
}
66+
# Carry the LastSeen stamp over - dropping it would make the scheduler's
67+
# post-run check read the surviving items as stale and clear them.
68+
if ($Row.LastSeen) { $Entity.LastSeen = [string]$Row.LastSeen }
69+
$Table.Entity = $Entity
70+
Add-CIPPAzDataTableEntity @Table -Force | Out-Null
71+
}
72+
}
73+
74+
$Result = if ($Removed -gt 0) {
75+
"Resolved alert: $($HashResult.ContentPreview)"
76+
} else {
77+
"Alert was already resolved: $($HashResult.ContentPreview)"
78+
}
79+
Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' -tenant $TenantFilter
80+
81+
return ([HttpResponseContext]@{
82+
StatusCode = [HttpStatusCode]::OK
83+
Body = @{ Results = $Result }
84+
})
85+
} catch {
86+
$ErrorMessage = Get-CippException -Exception $_
87+
Write-LogMessage -headers $Headers -API $APIName -message "Failed to resolve alert: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter
88+
return ([HttpResponseContext]@{
89+
StatusCode = [HttpStatusCode]::InternalServerError
90+
Body = @{ Results = "Failed to resolve alert: $($ErrorMessage.NormalizedError)" }
91+
})
92+
}
93+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Pester tests for Invoke-ExecResolveAlert
2+
3+
BeforeAll {
4+
# Resolve by name under Modules/ so the test survives the function moving between modules.
5+
$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
6+
$FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecResolveAlert.ps1' -File -ErrorAction SilentlyContinue |
7+
Select-Object -First 1 -ExpandProperty FullName
8+
if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecResolveAlert.ps1 under Modules/' }
9+
10+
# Azure Functions binding types do not exist outside the Functions host - fake them.
11+
class HttpResponseContext {
12+
[int]$StatusCode
13+
[object]$Body
14+
}
15+
# The Functions host profile provides 'using namespace System.Net'; outside it the
16+
# bare [HttpStatusCode] the function uses needs a type accelerator.
17+
$Accelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators')
18+
if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) {
19+
$Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode])
20+
}
21+
22+
# Stub every CIPP helper the function calls so Pester's Mock has a command to replace.
23+
function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
24+
function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
25+
function Get-CippException { param($Exception) }
26+
function Get-CIPPTable { param($tablename) }
27+
function Remove-AzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
28+
function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant) }
29+
30+
# Use the real hash and filter-escape helpers so the endpoint's matching and
31+
# injection hardening stay faithful to production behaviour.
32+
foreach ($HelperName in @('Get-AlertContentHash.ps1', 'ConvertTo-CIPPODataFilterValue.ps1')) {
33+
$HelperPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter $HelperName -File -ErrorAction SilentlyContinue |
34+
Select-Object -First 1 -ExpandProperty FullName
35+
if (-not $HelperPath) { throw "Could not locate $HelperName under Modules/" }
36+
. $HelperPath
37+
}
38+
39+
. $FunctionPath
40+
41+
function New-ResolveRequest {
42+
param($Body)
43+
[pscustomobject]@{
44+
Params = @{ CIPPEndpoint = 'ExecResolveAlert' }
45+
Headers = @{ Authorization = 'token' }
46+
Body = $Body
47+
}
48+
}
49+
50+
function New-AlertRow {
51+
param($PartitionKey, $Items)
52+
[pscustomobject]@{
53+
PartitionKey = $PartitionKey
54+
RowKey = 'contoso.com-Get-CIPPAlertMFAAdmins'
55+
CmdletName = 'Get-CIPPAlertMFAAdmins'
56+
Tenant = 'contoso.com'
57+
LogData = (ConvertTo-Json -InputObject @($Items) -Compress -Depth 10 | Out-String)
58+
AlertComment = ''
59+
}
60+
}
61+
}
62+
63+
Describe 'Invoke-ExecResolveAlert' {
64+
BeforeEach {
65+
Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertLastRun' } }
66+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { }
67+
Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { }
68+
Mock -CommandName Remove-AzDataTableEntity -MockWith { }
69+
Mock -CommandName Write-LogMessage -MockWith { }
70+
Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = $Exception.Exception.Message } }
71+
72+
$script:TargetItem = [pscustomobject]@{ UserPrincipalName = 'admin@contoso.com'; Message = 'MFA disabled' }
73+
$script:OtherItem = [pscustomobject]@{ UserPrincipalName = 'other@contoso.com'; Message = 'MFA disabled' }
74+
$script:ValidBody = [pscustomobject]@{
75+
CmdletName = 'Get-CIPPAlertMFAAdmins'
76+
TenantFilter = 'contoso.com'
77+
AlertItem = $script:TargetItem
78+
}
79+
}
80+
81+
It 'removes the matching item and rewrites the row when other items remain' {
82+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
83+
New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem, $script:OtherItem)
84+
}
85+
86+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
87+
88+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
89+
$response.Body.Results | Should -Match '^Resolved alert'
90+
Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
91+
$Filter -eq "RowKey eq 'contoso.com-Get-CIPPAlertMFAAdmins'"
92+
}
93+
Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
94+
$Entity.LogData -notmatch 'admin@contoso\.com' -and $Entity.LogData -match 'other@contoso\.com'
95+
}
96+
Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly
97+
}
98+
99+
It 'deletes the row entirely when the last item is resolved' {
100+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
101+
New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem)
102+
}
103+
104+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
105+
106+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
107+
Should -Invoke Remove-AzDataTableEntity -Times 1 -Exactly
108+
Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly
109+
}
110+
111+
It 'removes the item from every partition so older rows cannot resurface it' {
112+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
113+
@(
114+
New-AlertRow -PartitionKey '20260715' -Items @($script:TargetItem)
115+
New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem)
116+
)
117+
}
118+
119+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
120+
121+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
122+
Should -Invoke Remove-AzDataTableEntity -Times 2 -Exactly
123+
}
124+
125+
It 'returns OK with an already-resolved message when no stored item matches' {
126+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
127+
New-AlertRow -PartitionKey '20260716' -Items @($script:OtherItem)
128+
}
129+
130+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
131+
132+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
133+
$response.Body.Results | Should -Match 'already resolved'
134+
Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly
135+
Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly
136+
}
137+
138+
It 'preserves the LastSeen stamp when rewriting a row' {
139+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
140+
$Row = New-AlertRow -PartitionKey '20260716' -Items @($script:TargetItem, $script:OtherItem)
141+
$Row | Add-Member -NotePropertyName LastSeen -NotePropertyValue '1784216400' -PassThru
142+
}
143+
144+
$null = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
145+
146+
Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
147+
$Entity.LastSeen -eq '1784216400'
148+
}
149+
}
150+
151+
It 'escapes quotes in caller input so the filter cannot be widened' {
152+
$Body = [pscustomobject]@{
153+
CmdletName = 'Get-CIPPAlertMFAAdmins'
154+
TenantFilter = "x' or RowKey ge '"
155+
AlertItem = $script:TargetItem
156+
}
157+
158+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $Body) -TriggerMetadata $null
159+
160+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)
161+
Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
162+
$Filter -eq "RowKey eq 'x'' or RowKey ge ''-Get-CIPPAlertMFAAdmins'"
163+
}
164+
}
165+
166+
It 'returns BadRequest when a required field is missing' {
167+
$Body = [pscustomobject]@{ CmdletName = ''; TenantFilter = 'contoso.com'; AlertItem = $script:TargetItem }
168+
169+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $Body) -TriggerMetadata $null
170+
171+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest)
172+
$response.Body.Results | Should -Match 'required'
173+
}
174+
175+
It 'returns InternalServerError and logs when the table query throws' {
176+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { throw 'table unavailable' }
177+
178+
$response = Invoke-ExecResolveAlert -Request (New-ResolveRequest $script:ValidBody) -TriggerMetadata $null
179+
180+
$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError)
181+
$response.Body.Results | Should -Match 'Failed to resolve alert'
182+
Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' }
183+
}
184+
}

0 commit comments

Comments
 (0)