From ea5ad21e47723e36bba02dd239a1e695d4739b24 Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Mon, 22 Jun 2026 13:24:46 -0600 Subject: [PATCH] feat(alerting): add per-alert snooze and silent auto-resolve Per-alert snooze (4h / 24h / 7d presets) plus silent auto-resolve on metric recovery. Snooze data lives on TriggeredAlert and composes with the existing per-policy SuppressionMinutes. Schema: adds SnoozedUntil, SnoozedBy, BelowThresholdStreakCount, and LastEvaluatedAt columns to TriggeredAlerts via idempotent IF COL_LENGTH ALTER blocks, mirroring the existing SourceFailureDetails pattern. API: POST /api/triggered-alerts/{id}/snooze accepts an absolute timestamp or a duration in hours; POST .../unsnooze clears the state. Snoozing a terminal-state alert returns 400. Evaluator: after the existing dispatch loop, scans non-terminal alerts, increments or resets a per-alert streak counter on each evaluation, and transitions to Status="auto_resolved" after AutoResolveDebounceCycles (default 2) consecutive below-threshold observations. Resolution is silent - no notification dispatch, no NotificationLog write. Manual resolve remains the user-driven terminal state; the first transition wins. Config: new Alerting section in appsettings.json; bound via the existing IOptions pattern. --- .../Data/AlertingSchema.cs | 12 ++++++ .../Models/AlertingOptions.cs | 16 +++++++ .../Models/TriggeredAlert.cs | 13 ++++++ src/M365SecurityDashboard.Api/Program.cs | 33 +++++++++++++++ .../Services/AlertEvaluator.cs | 42 ++++++++++++++++++- .../appsettings.json | 3 ++ 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/M365SecurityDashboard.Api/Models/AlertingOptions.cs diff --git a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs index 72a0cef..19994ce 100644 --- a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs +++ b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs @@ -76,6 +76,18 @@ public static class AlertingSchema IF COL_LENGTH(N'[CollectionRuns]', 'SourceFailureDetails') IS NULL ALTER TABLE [CollectionRuns] ADD [SourceFailureDetails] nvarchar(max) NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedUntil') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [SnoozedUntil] datetimeoffset NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedBy') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [SnoozedBy] nvarchar(120) NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'BelowThresholdStreakCount') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [BelowThresholdStreakCount] int NOT NULL CONSTRAINT [DF_TriggeredAlerts_Streak] DEFAULT 0; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'LastEvaluatedAt') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [LastEvaluatedAt] datetimeoffset NULL; """; private static readonly (string Name, string Category, string Metric, int Threshold, string Severity, string Condition)[] Defaults = diff --git a/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs new file mode 100644 index 0000000..5890f37 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs @@ -0,0 +1,16 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Configuration for the server-side alerting engine. Bound from the +/// "Alerting" section of appsettings.json. +/// +public sealed class AlertingOptions +{ + /// + /// Number of consecutive evaluation cycles the underlying metric must be + /// below a triggered alert's threshold before the alert auto-resolves. + /// With the default collection interval of 15 minutes and a value of 2, + /// an alert auto-resolves ~30 minutes after the metric recovers. + /// + public int AutoResolveDebounceCycles { get; set; } = 2; +} diff --git a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs index 9ce7a47..a23425c 100644 --- a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs +++ b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs @@ -39,4 +39,17 @@ public sealed class TriggeredAlert /// Whether outbound notifications were dispatched for this alert. public bool Notified { get; set; } + + /// If set, the alert is silenced until this timestamp. Status remains "new" or "acknowledged". + public DateTimeOffset? SnoozedUntil { get; set; } + + /// Identity of the actor who snoozed this alert. Placeholder string ("dashboard") until auth lands. + [MaxLength(120)] + public string? SnoozedBy { get; set; } + + /// Number of consecutive evaluation cycles the underlying metric has been below this alert's threshold. Reset on any above-threshold observation. + public int BelowThresholdStreakCount { get; set; } + + /// When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce). + public DateTimeOffset? LastEvaluatedAt { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Program.cs b/src/M365SecurityDashboard.Api/Program.cs index 73e8225..dec3942 100644 --- a/src/M365SecurityDashboard.Api/Program.cs +++ b/src/M365SecurityDashboard.Api/Program.cs @@ -10,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(); builder.Services.Configure(builder.Configuration.GetSection("Graph")); +builder.Services.Configure(builder.Configuration.GetSection("Alerting")); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddHttpClient(); @@ -1077,6 +1078,35 @@ app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, G return Results.Ok(t); }); +// Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }. +// Until wins if both are supplied; durationHours defaults to 24 if neither is supplied. +app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async ( + AppDbContext db, Guid id, SnoozeRequest input, CancellationToken ct) => +{ + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + if (t.Status is "resolved" or "auto_resolved") + return Results.BadRequest(new { error = "Cannot snooze a terminal alert." }); + + var until = input.Until + ?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(h) : DateTimeOffset.UtcNow.AddHours(24)); + t.SnoozedUntil = until; + t.SnoozedBy = "dashboard"; + await db.SaveChangesAsync(ct); + return Results.Ok(t); +}); + +app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async ( + AppDbContext db, Guid id, CancellationToken ct) => +{ + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.SnoozedUntil = null; + t.SnoozedBy = null; + await db.SaveChangesAsync(ct); + return Results.Ok(t); +}); + // Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check) app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) => { @@ -1150,3 +1180,6 @@ app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct app.MapFallbackToFile("index.html"); app.Run(); + +/// Body shape for POST /api/triggered-alerts/{id}/snooze. +public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours); diff --git a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs index 7c62dcc..c8c951e 100644 --- a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs +++ b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs @@ -1,6 +1,7 @@ using M365SecurityDashboard.Api.Data; using M365SecurityDashboard.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace M365SecurityDashboard.Api.Services; @@ -8,10 +9,12 @@ namespace M365SecurityDashboard.Api.Services; /// Evaluates all enabled rows against the latest /// collected data and persists new rows. Runs /// server-side after every collection cycle so alerts fire without a browser. +/// Also auto-resolves stale alerts whose underlying metric has recovered. /// public sealed class AlertEvaluator( AppDbContext db, NotificationSender sender, + IOptions options, ILogger logger) { public async Task EvaluateAsync(CancellationToken ct) @@ -26,6 +29,10 @@ public sealed class AlertEvaluator( var now = DateTimeOffset.UtcNow; var fired = 0; + // Map PolicyId -> Metric key so the auto-resolve loop below can look up + // each open alert's current metric without re-querying the policy table. + var policyMetricById = policies.ToDictionary(p => p.Id, p => p.Metric); + foreach (var policy in policies) { var value = metrics.GetValueOrDefault(policy.Metric, 0); @@ -67,10 +74,41 @@ public sealed class AlertEvaluator( } } - if (fired > 0) + // Auto-resolve: scan non-terminal alerts and update streak counters. + // Resolves silently — no notification dispatch, no NotificationLog write. + var streakTarget = Math.Max(1, options.Value.AutoResolveDebounceCycles); + var openAlerts = await db.TriggeredAlerts + .Where(t => t.Status != "resolved" && t.Status != "auto_resolved") + .ToListAsync(ct); + var autoResolved = 0; + foreach (var alert in openAlerts) + { + if (!policyMetricById.TryGetValue(alert.PolicyId, out var metricKey)) continue; + var current = metrics.GetValueOrDefault(metricKey, 0); + + if (current < alert.Threshold) + { + alert.BelowThresholdStreakCount++; + if (alert.BelowThresholdStreakCount >= streakTarget) + { + alert.Status = "auto_resolved"; + autoResolved++; + } + } + else if (alert.BelowThresholdStreakCount != 0) + { + alert.BelowThresholdStreakCount = 0; + } + alert.LastEvaluatedAt = now; + } + + if (fired > 0 || autoResolved > 0) { await db.SaveChangesAsync(ct); - logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); + if (fired > 0) + logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); + if (autoResolved > 0) + logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved); } return fired; } diff --git a/src/M365SecurityDashboard.Api/appsettings.json b/src/M365SecurityDashboard.Api/appsettings.json index 86758ae..24cf5eb 100644 --- a/src/M365SecurityDashboard.Api/appsettings.json +++ b/src/M365SecurityDashboard.Api/appsettings.json @@ -13,5 +13,8 @@ "ExchangeQuarantinePath": "/beta/security/alerts_v2?$top=50&$filter=serviceSource%20eq%20'microsoftDefenderForOffice365'%20and%20category%20eq%20'EmailMalware'", "MailFlowIssuesPath": "/v1.0/admin/serviceAnnouncement/issues?$top=50&$filter=service%20eq%20'Exchange%20Online'%20and%20isResolved%20eq%20false" }, + "Alerting": { + "AutoResolveDebounceCycles": 2 + }, "AllowedHosts": "*" }