feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus

New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.

Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.

Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).

Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-27 18:39:22 +02:00
parent 7680bdff7b
commit 7b975d46b6
7 changed files with 418 additions and 1 deletions
@@ -0,0 +1,172 @@
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
/// parsing is defensive (only the alert rule name is mandatory).
/// </summary>
public class AzureMonitorAlertWebhookFunctionTests
{
// ── SecretMatches ──────────────────────────────────────────────────────
[Fact]
public void SecretMatches_accepts_equal_secrets()
{
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
}
[Theory]
[InlineData("wrong", "right")]
[InlineData("right-but-longer", "right")]
[InlineData("", "configured")]
[InlineData(null, "configured")]
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
{
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void SecretMatches_fails_closed_when_not_configured(string? configured)
{
// While the app setting is unset, NOTHING matches — not even an empty provided value.
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
}
// ── MapSeverity ────────────────────────────────────────────────────────
[Theory]
[InlineData("Sev0", OpsEventSeverity.Critical)]
[InlineData("Sev1", OpsEventSeverity.Error)]
[InlineData("Sev2", OpsEventSeverity.Warning)]
[InlineData("Sev3", OpsEventSeverity.Info)]
[InlineData("Sev4", OpsEventSeverity.Info)]
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
{
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Sev5")]
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
{
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
// ── TryParse ───────────────────────────────────────────────────────────
private const string FullPayload = """
{
"schemaId": "azureMonitorCommonAlertSchema",
"data": {
"essentials": {
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
"alertRule": "Portal RSC prefetch tail",
"severity": "Sev2",
"signalType": "Log",
"monitorCondition": "Fired",
"monitoringService": "Log Alerts V2",
"alertTargetIDs": [
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
],
"description": "RSC prefetches exceeding 10s",
"firedDateTime": "2026-07-27T15:30:00.000Z"
},
"alertContext": {
"condition": {
"windowSize": "PT1H",
"allOf": [
{
"searchQuery": "dependencies | where name contains '_rsc='",
"metricValue": 18.0,
"operator": "GreaterThan",
"threshold": "0"
}
]
}
}
}
}
""";
[Fact]
public void TryParse_extracts_essentials_and_metric_value()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
Assert.NotNull(alert);
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
Assert.Equal("Sev2", alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition);
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
Assert.Equal("Log Alerts V2", alert.MonitoringService);
// No targetResourceName in the payload → resource name segment of the first target ID.
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
Assert.Equal(18.0, alert.MetricValue);
}
[Fact]
public void TryParse_prefers_explicit_target_resource_name()
{
const string payload = """
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
"alertTargetIDs":["/sub/x/other-name"]}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.Equal("explicit-name", alert!.TargetResource);
}
[Fact]
public void TryParse_minimal_payload_needs_only_the_alert_rule()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
Assert.NotNull(alert);
Assert.Equal("minimal", alert!.AlertRule);
Assert.Null(alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
Assert.Null(alert.Description);
Assert.Null(alert.MetricValue);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("{}")] // no data
[InlineData("""{"data":{}}""")] // no essentials
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
public void TryParse_rejects_unusable_payloads(string? body)
{
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
}
[Fact]
public void TryParse_tolerates_malformed_alert_context()
{
// alertContext with unexpected shapes must not break essentials extraction.
const string payload = """
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.NotNull(alert);
Assert.Equal("Sev0", alert!.Severity);
Assert.Null(alert.MetricValue);
}
}
@@ -0,0 +1,209 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
/// <summary>
/// Receives Azure Monitor alert notifications (Common Alert Schema) from an Action Group
/// webhook receiver and re-emits them as ops events, so platform alerts flow through the
/// same OpsEventService → OpsAlertDispatchService fan-out (Telegram/Teams/Slack providers +
/// the portal ops-events list) as every internally raised operational event.
///
/// The delivery path is deliberately independent of the portal: Azure Monitor → this
/// function → providers. A portal (SWA) outage cannot suppress these notifications — the
/// action group's e-mail receiver additionally stays as the backend-independent fallback,
/// because a self-hosted channel cannot alert on its own outage.
///
/// Security: anonymous route (Action Group webhook receivers can only call a bare URI — no
/// custom headers), gated by a shared secret in the query string compared in constant time
/// against the OpsAlertWebhookSecret app setting. Fail-closed: while the setting is unset,
/// every request is rejected. Dispatch to providers only happens if an enabled ops alert
/// rule for EventType "AzureMonitorAlert" matches the mapped severity — the event itself is
/// always stored.
/// </summary>
public class AzureMonitorAlertWebhookFunction
{
internal const string SecretSettingName = "OpsAlertWebhookSecret";
// Common Alert Schema payloads for log-search alerts carry the search query, link URLs
// and dimension sets — a few KB in practice. 64 KB is a generous shape gate, not a throttle.
internal const int MaxContentLength = 64 * 1024;
private readonly OpsEventService _opsEventService;
private readonly ILogger<AzureMonitorAlertWebhookFunction> _logger;
public AzureMonitorAlertWebhookFunction(
OpsEventService opsEventService,
ILogger<AzureMonitorAlertWebhookFunction> logger)
{
_opsEventService = opsEventService;
_logger = logger;
}
[Function("AzureMonitorAlertWebhook")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "ops/alert-webhook")] HttpRequestData req)
{
var configuredSecret = Environment.GetEnvironmentVariable(SecretSettingName);
var providedSecret = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["secret"];
if (!SecretMatches(providedSecret, configuredSecret))
{
// Deliberately no detail in the response; the warning makes a misconfigured
// (unset) secret operator-visible in the logs.
_logger.LogWarning(
"Azure Monitor alert webhook rejected: {Reason}",
string.IsNullOrEmpty(configuredSecret) ? "secret not configured (fail-closed)" : "secret mismatch");
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
if (req.Body.Length > MaxContentLength)
{
_logger.LogWarning("Azure Monitor alert webhook rejected: payload {Length} bytes exceeds cap", req.Body.Length);
return req.CreateResponse(HttpStatusCode.BadRequest);
}
string body;
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
body = await reader.ReadToEndAsync();
var alert = TryParse(body);
if (alert == null)
{
_logger.LogWarning("Azure Monitor alert webhook rejected: body is not a Common Alert Schema payload");
return req.CreateResponse(HttpStatusCode.BadRequest);
}
await _opsEventService.RecordAzureMonitorAlertAsync(
alert.AlertRule,
MapSeverity(alert.Severity),
alert.MonitorCondition,
alert.Description,
alert.Severity,
alert.MonitoringService,
alert.TargetResource,
alert.MetricValue);
_logger.LogInformation(
"Azure Monitor alert '{AlertRule}' {Condition} ({Severity}) recorded as ops event",
alert.AlertRule, alert.MonitorCondition, alert.Severity);
return req.CreateResponse(HttpStatusCode.OK);
}
/// <summary>
/// Constant-time secret comparison. Fail-closed: an unset/empty configured secret never
/// matches anything, so the endpoint stays dark until the app setting is provisioned.
/// </summary>
internal static bool SecretMatches(string? provided, string? configured)
{
if (string.IsNullOrEmpty(configured) || string.IsNullOrEmpty(provided))
return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(configured));
}
/// <summary>
/// Maps Azure Monitor severities (Sev0 = most severe … Sev4 = verbose) onto the ops event
/// severity scale. Unknown values fall back to Info so a schema drift can never escalate.
/// </summary>
internal static string MapSeverity(string? azureSeverity) => azureSeverity switch
{
"Sev0" => Shared.DataAccess.OpsEventSeverity.Critical,
"Sev1" => Shared.DataAccess.OpsEventSeverity.Error,
"Sev2" => Shared.DataAccess.OpsEventSeverity.Warning,
_ => Shared.DataAccess.OpsEventSeverity.Info,
};
/// <summary>
/// Parsed subset of the Common Alert Schema. Only <see cref="AlertRule"/> is mandatory;
/// everything else is best-effort so a partial payload still produces a usable ops event.
/// </summary>
internal sealed record ParsedAlert(
string AlertRule,
string? Severity,
string MonitorCondition,
string? Description,
string? MonitoringService,
string? TargetResource,
double? MetricValue);
/// <summary>
/// Defensively extracts data.essentials (+ the first condition's metricValue) from a
/// Common Alert Schema payload. Returns null when the body is not JSON or carries no
/// alert rule name — the two states in which no meaningful ops event can be formed.
/// </summary>
internal static ParsedAlert? TryParse(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("essentials", out var essentials))
return null;
var alertRule = GetString(essentials, "alertRule");
if (string.IsNullOrWhiteSpace(alertRule))
return null;
var targetResource = GetString(essentials, "targetResourceName");
if (string.IsNullOrEmpty(targetResource) &&
essentials.TryGetProperty("alertTargetIDs", out var targets) &&
targets.ValueKind == JsonValueKind.Array && targets.GetArrayLength() > 0 &&
targets[0].ValueKind == JsonValueKind.String)
{
// Fall back to the resource name segment of the first target resource ID.
var id = targets[0].GetString() ?? string.Empty;
targetResource = id[(id.LastIndexOf('/') + 1)..];
}
// Log-search alerts carry the observed value under alertContext.condition.allOf[0].
double? metricValue = null;
if (data.TryGetProperty("alertContext", out var ctx) &&
ctx.ValueKind == JsonValueKind.Object &&
ctx.TryGetProperty("condition", out var condition) &&
condition.ValueKind == JsonValueKind.Object &&
condition.TryGetProperty("allOf", out var allOf) &&
allOf.ValueKind == JsonValueKind.Array && allOf.GetArrayLength() > 0 &&
allOf[0].ValueKind == JsonValueKind.Object &&
allOf[0].TryGetProperty("metricValue", out var mv) &&
mv.ValueKind == JsonValueKind.Number)
{
metricValue = mv.GetDouble();
}
return new ParsedAlert(
alertRule!,
GetString(essentials, "severity"),
GetString(essentials, "monitorCondition") ?? "Fired",
GetString(essentials, "description"),
GetString(essentials, "monitoringService"),
targetResource,
metricValue);
}
catch (JsonException)
{
return null;
}
}
private static string? GetString(JsonElement element, string property)
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
}
@@ -170,6 +170,11 @@ public static class EndpointAccessPolicyCatalog
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
// MemberRead). See DiagnosticsTicketDownloadFunction.
new("GET", "diagnostics/download", EndpointPolicy.PublicAnonymous),
// Azure Monitor action-group webhook (Common Alert Schema → ops event). Action groups
// cannot send custom headers, so the shared secret travels as ?secret= and is compared
// in constant time against the OpsAlertWebhookSecret app setting (fail-closed while
// unset). See AzureMonitorAlertWebhookFunction.
new("POST", "ops/alert-webhook", EndpointPolicy.PublicAnonymous),
// ── DeviceOrBootstrapAuth ───────────────────────────────────────
new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth),
@@ -594,6 +594,24 @@ namespace AutopilotMonitor.Functions.Services
null, "System.SlaEvaluation",
new { tenantsEvaluated, breachesDetected, notificationsSent, durationMs });
// ── Platform (Azure Monitor) ───────────────────────────────────────────
/// <summary>
/// Records an Azure Monitor alert notification delivered via the ops alert webhook
/// (AzureMonitorAlertWebhookFunction). severity is already mapped onto the ops scale;
/// azureSeverity keeps the original Sev0–Sev4 value for the details payload.
/// </summary>
public Task RecordAzureMonitorAlertAsync(string alertRule, string severity, string monitorCondition,
string? description, string? azureSeverity, string? monitoringService, string? targetResource,
double? metricValue)
{
var suffix = string.IsNullOrWhiteSpace(description) ? string.Empty : $": {description}";
return WriteAsync(OpsEventCategory.Platform, "AzureMonitorAlert", severity,
$"Azure Monitor alert '{alertRule}' {monitorCondition}{suffix}",
null, "System.AzureMonitor",
new { alertRule, monitorCondition, azureSeverity, monitoringService, targetResource, metricValue });
}
// ── Core write method ──────────────────────────────────────────────────
private async Task WriteAsync(string category, string eventType, string severity,
@@ -45,6 +45,8 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string Tenant = "Tenant";
public const string Agent = "Agent";
public const string Sla = "SLA";
/// <summary>Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook).</summary>
public const string Platform = "Platform";
}
/// <summary>
@@ -110,6 +110,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// feedback_ops_event_types_dual_register.
"AgentEmergencyBreak",
],
Platform: [
// Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema →
// AzureMonitorAlertWebhookFunction → RecordAzureMonitorAlertAsync). One event type for ALL
// Azure Monitor alert rules — the rule name travels in the message/details. Severity is
// mapped Sev0→Critical, Sev1→Error, Sev2→Warning, Sev3/4→Info; both Fired and Resolved
// notifications carry the same mapped severity, so a rule here delivers recovery pings too.
// Dual-register per memory feedback_ops_event_types_dual_register.
"AzureMonitorAlert",
],
};
const SEVERITIES = ["Info", "Warning", "Error", "Critical"];
@@ -120,6 +129,7 @@ const CATEGORY_COLORS: Record<string, string> = {
Security: "text-orange-700 dark:text-orange-300",
Tenant: "text-teal-700 dark:text-teal-300",
Agent: "text-cyan-700 dark:text-cyan-300",
Platform: "text-sky-700 dark:text-sky-300",
};
interface OpsAlertRulesSectionProps {
@@ -42,9 +42,10 @@ const CATEGORY_STYLES: Record<string, string> = {
Security: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
Tenant: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
Agent: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300",
Platform: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
};
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent"];
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent", "Platform"];
const PAGE_SIZE = 20;