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": "*"
}