Skip to content

Commit 4139ef5

Browse files
committed
feat(alerts): auto-resolve cleared alerts
Fired alerts were written to AlertLastRun but nothing ever removed them when the underlying condition cleared, so dashboard alerts stayed active forever. The only workaround was snoozing, which is the wrong semantic for a resolved condition. Alert output can't signal "cleared" because it only carries changed data (it drives notifications, which must not repeat for unchanged conditions - alerts can recur every 30 minutes). Instead Write-AlertTrace stamps LastSeen on every run with active items, and the scheduler clears the stored state when a successful run stamped nothing. Clear-AlertTrace only deletes rows carrying LogData: CheckExtension keeps its last-run watermark in the same table under the same RowKey format and must survive. Known trade-off: an alert cmdlet that swallows a dependency failure internally reads as clean and clears; the alert returns with a fresh notification on the next successful run.
1 parent 9368bca commit 4139ef5

5 files changed

Lines changed: 212 additions & 31 deletions

File tree

Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ function Push-ExecScheduledCommand {
182182
}
183183

184184
$Function = $Command
185+
$CommandStartTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds
185186

186187
try {
187188
$PossibleParams = $Function.Parameters.Keys
@@ -284,6 +285,24 @@ function Push-ExecScheduledCommand {
284285
Write-Information "Results: $($results | ConvertTo-Json -Depth 10)"
285286
if ($item.command -like 'Get-CIPPAlert*') {
286287
Write-Information 'This is an alert task. Processing results as alerts.'
288+
if ($State -ne 'Failed') {
289+
# Output only carries CHANGED data (unchanged conditions stay silent so
290+
# notifications don't repeat), so it can't signal "condition cleared".
291+
# Write-AlertTrace instead stamps LastSeen on every run with active items;
292+
# no trace row stamped during this run means the condition no longer holds
293+
# (or every item is snoozed) - clear the stored state so the alert resolves
294+
# on the dashboard instead of lingering forever. Use the resolved command
295+
# name: rows are keyed by the function's defined name and table filters
296+
# are case-sensitive.
297+
$AlertTable = Get-CIPPTable -tablename 'AlertLastRun'
298+
$TraceRows = Get-CIPPAzDataTableEntity @AlertTable -Filter "RowKey eq '$Tenant-$($Command.Name)'"
299+
$StillActive = @($TraceRows | Where-Object {
300+
-not [string]::IsNullOrWhiteSpace($_.LogData) -and [int64]($_.LastSeen ?? 0) -ge $CommandStartTime
301+
})
302+
if ($StillActive.Count -eq 0) {
303+
Clear-AlertTrace -CmdletName $Command.Name -TenantFilter $Tenant
304+
}
305+
}
287306
$results = @($results)
288307
$TaskType = 'Alert'
289308
} else {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
function Clear-AlertTrace {
2+
<#
3+
.FUNCTIONALITY
4+
Internal function. Removes the stored AlertLastRun state for an alert so a resolved alert stops showing as active.
5+
#>
6+
param(
7+
$CmdletName,
8+
$TenantFilter
9+
)
10+
$Table = Get-CIPPTable -tablename AlertLastRun
11+
# Delete every partition for this alert, not just today's: date-partitioned rows from
12+
# earlier runs and the fixed partitions (BreachAlert, SecureScore) all key on this RowKey.
13+
# Only rows carrying LogData are trace rows - Get-CIPPAlertCheckExtension keeps its
14+
# last-run watermark in this table under the same RowKey format, and that must survive.
15+
$Rows = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$($TenantFilter)-$($CmdletName)'"
16+
$TraceRows = @($Rows | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LogData) })
17+
if ($TraceRows.Count -gt 0) {
18+
Remove-AzDataTableEntity -Force @Table -Entity $TraceRows | Out-Null
19+
Write-Information "Cleared AlertLastRun state for cmdlet '$CmdletName' and tenant '$TenantFilter' ($($TraceRows.Count) row(s))."
20+
}
21+
}

Modules/CIPPCore/Public/GraphHelper/Write-AlertTrace.ps1

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,39 +18,36 @@ function Write-AlertTrace {
1818
}
1919

2020
$Table = Get-CIPPTable -tablename AlertLastRun
21-
#Get current row and compare the $logData object. If it's the same, don't write it.
2221
$Row = Get-CIPPAzDataTableEntity @table -Filter "RowKey eq '$($tenantFilter)-$($cmdletName)' and PartitionKey eq '$PartitionKey'"
23-
try {
24-
$RowData = $Row.LogData
25-
$Compare = Compare-Object $RowData (ConvertTo-Json -InputObject $data -Compress -Depth 10 | Out-String)
26-
Write-Host "Comparing new alert data with existing data for cmdlet '$cmdletName' and tenant '$tenantFilter'. Differences: $Compare"
27-
if ($Compare) {
28-
$LogData = ConvertTo-Json -InputObject $data -Compress -Depth 10 | Out-String
29-
$TableRow = @{
30-
'PartitionKey' = $PartitionKey
31-
'RowKey' = "$($tenantFilter)-$($cmdletName)"
32-
'CmdletName' = "$cmdletName"
33-
'Tenant' = "$tenantFilter"
34-
'LogData' = [string]$LogData
35-
'AlertComment' = [string]$AlertComment
36-
}
37-
$Table.Entity = $TableRow
38-
Add-CIPPAzDataTableEntity @Table -Force | Out-Null
39-
return $data
22+
$LogData = ConvertTo-Json -InputObject $data -Compress -Depth 10 | Out-String
23+
$Changed = $true
24+
if ($Row) {
25+
try {
26+
$Changed = [bool](Compare-Object $Row.LogData $LogData)
27+
} catch {
28+
$Changed = $true
4029
}
41-
} catch {
42-
$LogData = ConvertTo-Json -InputObject $data -Compress -Depth 10 | Out-String
43-
$TableRow = @{
44-
'PartitionKey' = $PartitionKey
45-
'RowKey' = "$($tenantFilter)-$($cmdletName)"
46-
'CmdletName' = "$cmdletName"
47-
'Tenant' = "$tenantFilter"
48-
'LogData' = [string]$LogData
49-
'AlertComment' = [string]$AlertComment
50-
}
51-
$Table.Entity = $TableRow
52-
Add-CIPPAzDataTableEntity @Table -Force | Out-Null
53-
return $data
30+
Write-Host "Comparing new alert data with existing data for cmdlet '$cmdletName' and tenant '$tenantFilter'. Changed: $Changed"
31+
}
32+
33+
# Written on every run with active items: LastSeen tells the scheduler the alert is
34+
# still firing even when the data is unchanged, so it can clear resolved alerts.
35+
$TableRow = @{
36+
'PartitionKey' = $PartitionKey
37+
'RowKey' = "$($tenantFilter)-$($cmdletName)"
38+
'CmdletName' = "$cmdletName"
39+
'Tenant' = "$tenantFilter"
40+
'LogData' = [string]$LogData
41+
'AlertComment' = [string]$AlertComment
42+
'LastSeen' = [string][int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds
5443
}
44+
$Table.Entity = $TableRow
45+
Add-CIPPAzDataTableEntity @Table -Force | Out-Null
5546

47+
# Only changed data is returned: the caller's output drives notifications, and an
48+
# unchanged persisting condition must not re-notify on every run (alerts can recur
49+
# every 30 minutes, and the fixed-partition alerts keep one row across days).
50+
if ($Changed) {
51+
return $data
52+
}
5653
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Pester tests for Clear-AlertTrace
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 'Clear-AlertTrace.ps1' -File -ErrorAction SilentlyContinue |
7+
Select-Object -First 1 -ExpandProperty FullName
8+
if (-not $FunctionPath) { throw 'Could not locate Clear-AlertTrace.ps1 under Modules/' }
9+
10+
# Stub every CIPP helper the function calls so Pester's Mock has a command to replace.
11+
function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
12+
function Get-CIPPTable { param($tablename) }
13+
function Remove-AzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
14+
15+
. $FunctionPath
16+
}
17+
18+
Describe 'Clear-AlertTrace' {
19+
BeforeEach {
20+
Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertLastRun' } }
21+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { }
22+
Mock -CommandName Remove-AzDataTableEntity -MockWith { }
23+
}
24+
25+
It 'deletes every AlertLastRun trace row for the tenant+cmdlet RowKey' {
26+
$Rows = @(
27+
[pscustomobject]@{ PartitionKey = '20260715'; RowKey = 'contoso.com-Get-CIPPAlertApnCertExpiry'; LogData = '[{"Message":"expiring"}]' }
28+
[pscustomobject]@{ PartitionKey = '20260716'; RowKey = 'contoso.com-Get-CIPPAlertApnCertExpiry'; LogData = '[{"Message":"expiring"}]' }
29+
)
30+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $Rows }
31+
32+
Clear-AlertTrace -CmdletName 'Get-CIPPAlertApnCertExpiry' -TenantFilter 'contoso.com'
33+
34+
Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
35+
$Filter -eq "RowKey eq 'contoso.com-Get-CIPPAlertApnCertExpiry'"
36+
}
37+
Should -Invoke Remove-AzDataTableEntity -Times 1 -Exactly -ParameterFilter {
38+
@($Entity).Count -eq 2 -and $Force -eq $true
39+
}
40+
}
41+
42+
It 'spares rows without LogData, like the CheckExtension last-run watermark' {
43+
$Rows = @(
44+
[pscustomobject]@{ PartitionKey = 'AlertLastRun'; RowKey = 'contoso.com-Get-CIPPAlertCheckExtension'; LastRunTime = '2026-07-16T00:00:00Z' }
45+
[pscustomobject]@{ PartitionKey = '20260716'; RowKey = 'contoso.com-Get-CIPPAlertCheckExtension'; LogData = '[{"Message":"phish"}]' }
46+
)
47+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $Rows }
48+
49+
Clear-AlertTrace -CmdletName 'Get-CIPPAlertCheckExtension' -TenantFilter 'contoso.com'
50+
51+
Should -Invoke Remove-AzDataTableEntity -Times 1 -Exactly -ParameterFilter {
52+
@($Entity).Count -eq 1 -and @($Entity)[0].PartitionKey -eq '20260716'
53+
}
54+
}
55+
56+
It 'does not call Remove-AzDataTableEntity when only non-trace rows exist' {
57+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
58+
[pscustomobject]@{ PartitionKey = 'AlertLastRun'; RowKey = 'contoso.com-Get-CIPPAlertCheckExtension'; LastRunTime = '2026-07-16T00:00:00Z' }
59+
}
60+
61+
Clear-AlertTrace -CmdletName 'Get-CIPPAlertCheckExtension' -TenantFilter 'contoso.com'
62+
63+
Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly
64+
}
65+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Pester tests for Write-AlertTrace
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 'Write-AlertTrace.ps1' -File -ErrorAction SilentlyContinue |
7+
Select-Object -First 1 -ExpandProperty FullName
8+
if (-not $FunctionPath) { throw 'Could not locate Write-AlertTrace.ps1 under Modules/' }
9+
10+
# Stub every CIPP helper the function calls so Pester's Mock has a command to replace.
11+
function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) }
12+
function Get-CIPPAzDataTableEntity { param($TableName, $Filter) }
13+
function Get-CIPPTable { param($tablename) }
14+
function Remove-SnoozedAlerts { param($Data, $CmdletName, $TenantFilter) }
15+
16+
. $FunctionPath
17+
}
18+
19+
Describe 'Write-AlertTrace' {
20+
BeforeEach {
21+
Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertLastRun' } }
22+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { }
23+
Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { }
24+
# Pass items through unfiltered (nothing snoozed) unless a test overrides this.
25+
Mock -CommandName Remove-SnoozedAlerts -MockWith { $Data }
26+
27+
$script:AlertData = @([pscustomobject]@{ UserPrincipalName = 'admin@contoso.com'; Message = 'MFA disabled' })
28+
}
29+
30+
It 'writes a new row and returns the data when no row exists for today' {
31+
$Result = Write-AlertTrace -cmdletName 'Get-CIPPAlertMFAAdmins' -tenantFilter 'contoso.com' -data $script:AlertData
32+
33+
Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
34+
$Entity.RowKey -eq 'contoso.com-Get-CIPPAlertMFAAdmins' -and
35+
$Entity.CmdletName -eq 'Get-CIPPAlertMFAAdmins' -and
36+
-not [string]::IsNullOrWhiteSpace($Entity.LastSeen)
37+
}
38+
@($Result).Count | Should -Be 1
39+
@($Result)[0].UserPrincipalName | Should -Be 'admin@contoso.com'
40+
}
41+
42+
It 'writes a new row and returns the data when the stored data differs' {
43+
$OldData = ConvertTo-Json -InputObject @([pscustomobject]@{ UserPrincipalName = 'other@contoso.com' }) -Compress -Depth 10 | Out-String
44+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
45+
[pscustomobject]@{ RowKey = 'contoso.com-Get-CIPPAlertMFAAdmins'; LogData = $OldData }
46+
}
47+
48+
$Result = Write-AlertTrace -cmdletName 'Get-CIPPAlertMFAAdmins' -tenantFilter 'contoso.com' -data $script:AlertData
49+
50+
Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly
51+
@($Result)[0].UserPrincipalName | Should -Be 'admin@contoso.com'
52+
}
53+
54+
It 'refreshes LastSeen but returns nothing when the stored data is unchanged' {
55+
# Output drives notifications, so an unchanged persisting condition must stay
56+
# silent; the LastSeen stamp is what tells the scheduler the alert still fires.
57+
$StoredData = ConvertTo-Json -InputObject $script:AlertData -Compress -Depth 10 | Out-String
58+
Mock -CommandName Get-CIPPAzDataTableEntity -MockWith {
59+
[pscustomobject]@{ RowKey = 'contoso.com-Get-CIPPAlertMFAAdmins'; LogData = $StoredData }
60+
}
61+
62+
$Result = Write-AlertTrace -cmdletName 'Get-CIPPAlertMFAAdmins' -tenantFilter 'contoso.com' -data $script:AlertData
63+
64+
Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter {
65+
-not [string]::IsNullOrWhiteSpace($Entity.LastSeen)
66+
}
67+
$Result | Should -BeNullOrEmpty
68+
}
69+
70+
It 'returns null and writes nothing when every item is snoozed' {
71+
Mock -CommandName Remove-SnoozedAlerts -MockWith { @() }
72+
73+
$Result = Write-AlertTrace -cmdletName 'Get-CIPPAlertMFAAdmins' -tenantFilter 'contoso.com' -data $script:AlertData
74+
75+
$Result | Should -BeNullOrEmpty
76+
Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly
77+
Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -Exactly
78+
}
79+
}

0 commit comments

Comments
 (0)