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 { /// /// 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. /// 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 _logger; public AzureMonitorAlertWebhookFunction( OpsEventService opsEventService, ILogger logger) { _opsEventService = opsEventService; _logger = logger; } [Function("AzureMonitorAlertWebhook")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "ops/alert-webhook")] HttpRequestData req) { var configuredSecret = Environment.GetEnvironmentVariable(SecretSettingName); var providedSecret = req.Query["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); } // Bounded read instead of a Body.Length check: with ASP.NET Core integration the // request body is a forward-only stream whose Length property throws // NotSupportedException. Reading at most cap+1 chars detects oversize without ever // needing to know the total size. (Char-based cap ≈ byte cap for a shape gate.) string body; using (var reader = new StreamReader(req.Body, Encoding.UTF8)) { var buffer = new char[MaxContentLength + 1]; var read = await reader.ReadBlockAsync(buffer, 0, buffer.Length); if (read > MaxContentLength) { _logger.LogWarning("Azure Monitor alert webhook rejected: payload exceeds {Cap} char cap", MaxContentLength); return req.CreateResponse(HttpStatusCode.BadRequest); } body = new string(buffer, 0, read); } 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); } /// /// 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. /// 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)); } /// /// 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. /// 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, }; /// /// Parsed subset of the Common Alert Schema. Only is mandatory; /// everything else is best-effort so a partial payload still produces a usable ops event. /// internal sealed record ParsedAlert( string AlertRule, string? Severity, string MonitorCondition, string? Description, string? MonitoringService, string? TargetResource, double? MetricValue); /// /// 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. /// 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; } }