feat(backend+web): generic JSON webhook provider + custom headers

Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable,
channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems,
automation, or an SMTP gateway like Postal - instead of only Teams/Slack.

Backend:
- GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity,
  title/summary/themeColor, primaryUrl (first openUrl action), facts[],
  sections[], actions[]; registered in WebhookNotificationService.
- NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/
  _failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved,
  consecutive_failures, test) for consumer-side routing.
- Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only,
  restricted-header denylist, fail-soft parse) for API-key auth; dispatcher
  gained optional customHeaders applied via HttpRequestMessage; threaded into
  all 12 tenant call sites (Ops/admin-config sites unchanged).
- TableConfigRepository persists the field; UpdateTenantConfigurationFunction
  validates it (object, string values, RFC-7230 token names, CRLF-injection
  guard, 8 KB / 25-header caps).

Web:
- Generic provider option + key->value WebhookHeaderEditor (rendered only for
  provider 20); leaving the generic provider clears local headers so a later
  switch back cannot revive stale auth headers; context/types wiring sends
  headers only when provider===20; report shows Generic JSON provider name.

ConfigVersion unchanged (backend-only, no agent payload). No new
Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP
client / retry by design). New GenericWebhookTests cover renderer, header
parsing/denylist, and save-time validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-19 14:34:18 +02:00
parent fb5c206018
commit 2d1db6c897
20 changed files with 575 additions and 19 deletions
@@ -0,0 +1,167 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the generic webhook provider: payload rendering, custom-header parsing/denylist,
/// and the save-time header validation.
/// </summary>
public class GenericWebhookTests
{
// ── GenericJsonRenderer ───────────────────────────────────────────────
[Fact]
public void GenericJsonRenderer_RendersStableSchema()
{
var alert = new NotificationAlert
{
EventType = "enrollment_succeeded",
Title = "✅ Enrollment Succeeded",
Summary = "Enrollment Succeeded: DESK-001",
Severity = NotificationSeverity.Success,
ThemeColor = "00B050",
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
};
var json = new GenericJsonRenderer().RenderToJson(alert);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal("1.0", root.GetProperty("schemaVersion").GetString());
Assert.Equal("enrollment_succeeded", root.GetProperty("eventType").GetString());
Assert.Equal("Success", root.GetProperty("severity").GetString());
Assert.Equal("Enrollment Succeeded: DESK-001", root.GetProperty("summary").GetString());
Assert.Equal("https://portal.example.com/sessions/abc", root.GetProperty("primaryUrl").GetString());
var facts = root.GetProperty("facts");
Assert.Equal(1, facts.GetArrayLength());
Assert.Equal("Device", facts[0].GetProperty("name").GetString());
Assert.Equal("DESK-001", facts[0].GetProperty("value").GetString());
}
[Fact]
public void GenericJsonRenderer_OmitsEventTypeAndSessionUrl_WhenAbsent()
{
var alert = new NotificationAlert
{
Title = "Test",
Summary = "Test",
Severity = NotificationSeverity.Info,
};
var json = new GenericJsonRenderer().RenderToJson(alert);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("eventType", out _));
Assert.False(root.TryGetProperty("primaryUrl", out _));
Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType);
}
[Theory]
[InlineData(true, "enrollment_succeeded")]
[InlineData(false, "enrollment_failed")]
public void BuildEnrollmentAlert_SetsEventType(bool success, string expected)
{
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
"DESK", "SN", "Contoso", "Model", success, failureReason: "x", duration: null);
Assert.Equal(expected, alert.EventType);
}
[Fact]
public void BuildHardwareRejectedAlert_SetsEventType()
{
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model", "SN");
Assert.Equal("hardware_rejected", alert.EventType);
}
// ── TenantConfiguration.GetGenericWebhookHeaders ──────────────────────
[Fact]
public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider()
{
var config = new TenantConfiguration
{
WebhookUrl = "https://hooks.slack.com/x",
WebhookProviderType = (int)WebhookProviderType.Slack,
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
};
Assert.Empty(config.GetGenericWebhookHeaders());
}
[Fact]
public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider()
{
var config = new TenantConfiguration
{
WebhookUrl = "https://tickets.example.com/in",
WebhookProviderType = (int)WebhookProviderType.GenericJson,
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
};
var headers = config.GetGenericWebhookHeaders();
Assert.Equal("Bearer abc", headers["Authorization"]);
Assert.Equal("k1", headers["X-Api-Key"]);
}
[Fact]
public void GetGenericWebhookHeaders_DropsRestrictedHeaders()
{
var config = new TenantConfiguration
{
WebhookUrl = "https://tickets.example.com/in",
WebhookProviderType = (int)WebhookProviderType.GenericJson,
WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
};
var headers = config.GetGenericWebhookHeaders();
Assert.False(headers.ContainsKey("Host"));
Assert.False(headers.ContainsKey("Content-Length"));
Assert.True(headers.ContainsKey("X-Ok"));
}
[Fact]
public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson()
{
var config = new TenantConfiguration
{
WebhookUrl = "https://tickets.example.com/in",
WebhookProviderType = (int)WebhookProviderType.GenericJson,
WebhookCustomHeadersJson = "{not valid",
};
Assert.Empty(config.GetGenericWebhookHeaders());
}
// ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{\"Authorization\":\"Bearer abc\"}")]
public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
{
Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
}
[Theory]
[InlineData("{not json", "not valid JSON.")]
[InlineData("[\"a\",\"b\"]", "must be a JSON object")]
[InlineData("{\"X-Key\":123}", "must have a string value")]
[InlineData("{\"Bad Header\":\"v\"}", "not a valid HTTP header name")]
[InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
{
var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
Assert.NotNull(error);
Assert.Contains(expectedFragment, error);
}
}
@@ -374,6 +374,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure },
{ "WebhookNotifyOnHardwareRejection", config.WebhookNotifyOnHardwareRejection },
{ "WebhookNotifyOnStart", config.WebhookNotifyOnStart },
{ "WebhookCustomHeadersJson", config.WebhookCustomHeadersJson },
{ "ShowScriptOutput", config.ShowScriptOutput },
{ "ShowEnrollmentSummary", config.ShowEnrollmentSummary },
{ "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds },
@@ -464,6 +465,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true,
WebhookNotifyOnHardwareRejection = entity.GetBoolean("WebhookNotifyOnHardwareRejection") ?? false,
WebhookNotifyOnStart = entity.GetBoolean("WebhookNotifyOnStart") ?? false,
WebhookCustomHeadersJson = entity.GetString("WebhookCustomHeadersJson"),
ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true,
ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"),
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var providerType = (WebhookProviderType)providerTypeInt;
var testAlert = NotificationAlertBuilder.BuildTestAlert();
var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert);
var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert, tenantConfig.GetGenericWebhookHeaders());
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -78,6 +78,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Teams Webhook URL: {teamsUrlError}" });
return badRequest;
}
var headersError = ValidateWebhookCustomHeaders(config.WebhookCustomHeadersJson);
if (headersError != null)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid custom headers: {headersError}" });
return badRequest;
}
// Only validate the customer-supplied SAS URL when the tenant has actually
// selected the CustomerSas destination. In Hosted mode the field is unused at
// runtime (see GetDiagnosticsUploadUrlFunction), so a stale/legacy value left
@@ -176,5 +183,76 @@ namespace AutopilotMonitor.Functions.Functions.Config
return response;
}
}
private const int MaxCustomHeadersJsonLength = 8192;
private const int MaxCustomHeaderCount = 25;
/// <summary>
/// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when
/// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF
/// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected
/// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders().
/// </summary>
internal static string? ValidateWebhookCustomHeaders(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
if (json.Length > MaxCustomHeadersJsonLength)
return $"too large (max {MaxCustomHeadersJsonLength} characters).";
System.Text.Json.JsonDocument doc;
try
{
doc = System.Text.Json.JsonDocument.Parse(json);
}
catch (System.Text.Json.JsonException)
{
return "not valid JSON.";
}
using (doc)
{
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
return "must be a JSON object of header name/value pairs.";
var count = 0;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (++count > MaxCustomHeaderCount)
return $"too many headers (max {MaxCustomHeaderCount}).";
if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String)
return $"header \"{prop.Name}\" must have a string value.";
if (!IsValidHeaderName(prop.Name))
return $"\"{prop.Name}\" is not a valid HTTP header name.";
var value = prop.Value.GetString();
if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0))
return $"value for \"{prop.Name}\" must not contain line breaks.";
}
}
return null;
}
/// <summary>Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators).</summary>
private static bool IsValidHeaderName(string name)
{
if (string.IsNullOrEmpty(name))
return false;
foreach (var ch in name)
{
var isTokenChar =
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
"!#$%&'*+-.^_`|~".IndexOf(ch) >= 0;
if (!isTokenChar)
return false;
}
return true;
}
}
}
@@ -870,6 +870,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return;
var providerType = (WebhookProviderType)providerTypeInt;
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
var sessionUrl = updatedSession != null
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
: null;
@@ -905,7 +906,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -950,7 +951,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -231,7 +231,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
if (!string.IsNullOrEmpty(webhookUrl) && providerTypeInt != 0)
{
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
_ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
_ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, config.GetGenericWebhookHeaders());
}
}
}
@@ -260,7 +260,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
isResume: isResume,
sessionUrl: sessionUrl);
await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, tenantConfig.GetGenericWebhookHeaders());
}
catch (Exception ex)
{
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Services
return;
var providerType = (WebhookProviderType)providerTypeInt;
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
var sessionUrl = updatedSession != null
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
: null;
@@ -59,7 +60,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -81,7 +82,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -0,0 +1,58 @@
using System.Linq;
using AutopilotMonitor.Shared.Models.Notifications;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Renders a NotificationAlert as a stable, channel-agnostic JSON payload for generic webhook
/// consumers (ticketing systems, automation, SMTP gateways such as Postal).
///
/// Payload contract (schemaVersion "1.0"):
/// {
/// "schemaVersion": "1.0",
/// "eventType": "enrollment_succeeded", // omitted when not set
/// "severity": "Success", // Info | Success | Warning | Error
/// "title": "...", "summary": "...", "themeColor": "00B050",
/// "primaryUrl": "https://.../sessions/{id}", // first openUrl action (session/dashboard link), omitted when none
/// "facts": [ { "name": "Device", "value": "..." } ],
/// "sections": [ { "title": "...", "text": "..." } ],
/// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ]
/// }
/// </summary>
public class GenericJsonRenderer : INotificationRenderer
{
/// <summary>Bump only on a breaking change to the payload contract above.</summary>
private const string SchemaVersion = "1.0";
public WebhookProviderType ProviderType => WebhookProviderType.GenericJson;
public string RenderToJson(NotificationAlert alert)
{
// First openUrl action: a session link for enrollment alerts, a dashboard/portal link for
// SLA/test alerts. Named generically (primaryUrl) since it is not always a session URL;
// consumers can still inspect actions[] (each carries its title) to disambiguate.
var primaryUrl = alert.Actions
.FirstOrDefault(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))?.Url;
var payload = new
{
schemaVersion = SchemaVersion,
eventType = string.IsNullOrEmpty(alert.EventType) ? null : alert.EventType,
severity = alert.Severity.ToString(),
title = alert.Title,
summary = alert.Summary,
themeColor = string.IsNullOrEmpty(alert.ThemeColor) ? null : alert.ThemeColor,
primaryUrl,
facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray(),
sections = alert.Sections.Select(s => new { title = s.Title, text = s.Text }).ToArray(),
actions = alert.Actions.Select(a => new { type = a.Type, title = a.Title, url = a.Url }).ToArray(),
};
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
});
}
}
}
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = success ? "enrollment_succeeded" : "enrollment_failed",
Title = title,
Summary = summary,
Severity = severity,
@@ -84,6 +85,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = isResume ? "preprovisioning_resumed" : "enrollment_started",
Title = title,
Summary = summary,
Severity = NotificationSeverity.Info,
@@ -127,6 +129,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = success ? "preprovisioning_completed" : "preprovisioning_failed",
Title = title,
Summary = summary,
Severity = severity,
@@ -150,6 +153,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
return new NotificationAlert
{
EventType = "test",
Title = "\ud83d\udd14 Test Notification",
Summary = "This is a test notification from Autopilot Monitor.",
Severity = NotificationSeverity.Info,
@@ -175,6 +179,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
return new NotificationAlert
{
EventType = "hardware_rejected",
Title = "\u26a0\ufe0f Hardware Not Whitelisted",
Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist",
Severity = NotificationSeverity.Warning,
@@ -298,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = "sla_breach",
Title = title,
Summary = summary,
Severity = NotificationSeverity.Warning,
@@ -346,6 +352,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = "sla_resolved",
Title = $"✅ SLA Breach Resolved: {label}",
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
Severity = NotificationSeverity.Success,
@@ -375,6 +382,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
var alert = new NotificationAlert
{
EventType = "consecutive_failures",
Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures",
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
Severity = NotificationSeverity.Error,
@@ -28,13 +28,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
[WebhookProviderType.TeamsLegacyConnector] = new LegacyTeamsConnectorRenderer(),
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
[WebhookProviderType.Slack] = new SlackRenderer(),
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
};
}
/// <summary>
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
/// </summary>
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
return;
@@ -50,8 +54,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync(webhookUrl, content);
var response = await PostAsync(webhookUrl, json, customHeaders);
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
@@ -69,8 +72,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// <summary>
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
/// </summary>
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
{
if (string.IsNullOrEmpty(webhookUrl))
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
@@ -93,8 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
try
{
var json = renderer.RenderToJson(alert);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync(webhookUrl, content);
var response = await PostAsync(webhookUrl, json, customHeaders);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
@@ -116,6 +120,25 @@ namespace AutopilotMonitor.Functions.Services.Notifications
}
}
/// <summary>
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
/// </summary>
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
{
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
if (customHeaders != null)
{
foreach (var header in customHeaders)
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return _http.SendAsync(request);
}
}
public class WebhookTestResult
@@ -551,7 +551,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
@@ -594,7 +594,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
@@ -682,7 +682,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
@@ -548,6 +548,15 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool WebhookNotifyOnStart { get; set; } = false;
/// <summary>
/// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }) sent with every
/// generic-webhook POST. Used for API-key authentication against ticketing systems / SMTP gateways.
/// Only applied when the effective provider is <see cref="Notifications.WebhookProviderType.GenericJson"/>.
/// Restricted headers (Host, Content-Length, Content-Type, etc.) are ignored — see
/// <see cref="GetGenericWebhookHeaders"/>.
/// </summary>
public string WebhookCustomHeadersJson { get; set; } = default!;
// ===== SLA TARGETS =====
/// <summary>
@@ -640,6 +649,64 @@ namespace AutopilotMonitor.Shared.Models
public bool GetEffectiveNotifyOnStart()
=> !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnStart : TeamsNotifyOnStart;
/// <summary>
/// HTTP headers that must never be set via the custom-header mechanism: framing/host/content
/// headers are owned by the HTTP client and overriding them breaks the request or enables
/// request-smuggling. Compared case-insensitively.
/// </summary>
private static readonly HashSet<string> RestrictedWebhookHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Host", "Content-Length", "Content-Type", "Transfer-Encoding", "Connection",
"Keep-Alive", "Upgrade", "TE", "Trailer", "Expect", "Proxy-Connection",
};
/// <summary>
/// Parses <see cref="WebhookCustomHeadersJson"/> into header name/value pairs for the generic
/// webhook dispatcher. Returns an empty dictionary unless the effective provider is
/// <see cref="Notifications.WebhookProviderType.GenericJson"/>, the JSON is a parseable object,
/// and after dropping restricted headers and blank names/values. Never throws.
/// </summary>
public IReadOnlyDictionary<string, string> GetGenericWebhookHeaders()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var (_, providerType) = GetEffectiveWebhookConfig();
if (providerType != (int)Notifications.WebhookProviderType.GenericJson)
return result;
if (string.IsNullOrWhiteSpace(WebhookCustomHeadersJson))
return result;
try
{
using var doc = JsonDocument.Parse(WebhookCustomHeadersJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return result;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Value.ValueKind != JsonValueKind.String)
continue;
var name = prop.Name.Trim();
var value = prop.Value.GetString();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrEmpty(value))
continue;
if (RestrictedWebhookHeaders.Contains(name))
continue;
result[name] = value!;
}
}
catch (JsonException)
{
// Malformed JSON → no custom headers (fail-soft; dispatch still proceeds).
}
return result;
}
/// <summary>
/// Checks if the tenant is currently disabled
/// Takes into account DisabledUntil if set
@@ -9,6 +9,13 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// </summary>
public class NotificationAlert
{
/// <summary>
/// Machine-readable event type for routing/filtering by generic webhook consumers
/// (e.g. "enrollment_succeeded", "enrollment_failed", "hardware_rejected", "sla_breach").
/// Only serialized by the generic JSON renderer; ignored by Teams/Slack renderers.
/// </summary>
public string? EventType { get; set; }
/// <summary>Main title, e.g. "Enrollment Succeeded".</summary>
public string Title { get; set; } = default!;
@@ -16,5 +16,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>Slack Incoming Webhook (Block Kit format).</summary>
Slack = 10,
/// <summary>
/// Generic JSON webhook. Posts a stable, channel-agnostic JSON payload (schemaVersion + eventType)
/// to any HTTP endpoint — for ticketing systems, automation, or SMTP gateways (e.g. Postal).
/// Supports per-tenant custom request headers for API-key authentication.
/// </summary>
GenericJson = 20,
}
}
@@ -197,6 +197,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
1: 'Teams (Legacy Connector)',
2: 'Teams (Workflow Webhook)',
10: 'Slack',
20: 'Generic JSON',
};
function formatValue(val: unknown): string {
@@ -153,6 +153,8 @@ interface TenantConfigContextValue {
setWebhookNotifyOnFailure: (v: boolean) => void;
webhookNotifyOnStart: boolean;
setWebhookNotifyOnStart: (v: boolean) => void;
webhookCustomHeaders: string;
setWebhookCustomHeaders: (v: string) => void;
testingWebhook: boolean;
testWebhookResult: { success: boolean; message: string } | null;
handleTestWebhook: () => Promise<void>;
@@ -334,6 +336,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [webhookNotifyOnSuccess, setWebhookNotifyOnSuccess] = useState(true);
const [webhookNotifyOnFailure, setWebhookNotifyOnFailure] = useState(true);
const [webhookNotifyOnStart, setWebhookNotifyOnStart] = useState(false);
const [webhookCustomHeaders, setWebhookCustomHeaders] = useState("");
const [testingWebhook, setTestingWebhook] = useState(false);
const [testWebhookResult, setTestWebhookResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -439,18 +442,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setWebhookNotifyOnSuccess(data.webhookNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(data.webhookNotifyOnFailure ?? true);
setWebhookNotifyOnStart(data.webhookNotifyOnStart ?? false);
setWebhookCustomHeaders(data.webhookCustomHeadersJson ?? "");
} else if (data.teamsWebhookUrl) {
setWebhookProviderType(1); // TeamsLegacyConnector
setWebhookUrl(data.teamsWebhookUrl);
setWebhookNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
setWebhookNotifyOnStart(data.teamsNotifyOnStart ?? false);
setWebhookCustomHeaders("");
} else {
setWebhookProviderType(0);
setWebhookUrl("");
setWebhookNotifyOnSuccess(true);
setWebhookNotifyOnFailure(true);
setWebhookNotifyOnStart(false);
setWebhookCustomHeaders("");
}
// SLA Targets
setSlaTargetSuccessRate(data.slaTargetSuccessRate ?? null);
@@ -650,6 +656,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
webhookNotifyOnSuccess,
webhookNotifyOnFailure,
webhookNotifyOnStart,
// Custom headers only apply to the generic provider; clear them otherwise so a
// provider switch does not leave stale auth headers persisted.
webhookCustomHeadersJson: webhookProviderType === 20 ? (webhookCustomHeaders || undefined) : undefined,
// Legacy compat: mirror to old fields during transition
teamsWebhookUrl: webhookProviderType === 1 ? (webhookUrl || undefined) : undefined,
teamsNotifyOnSuccess: webhookNotifyOnSuccess,
@@ -721,6 +730,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
webhookProviderType, webhookUrl, webhookNotifyOnSuccess, webhookNotifyOnFailure, webhookNotifyOnStart,
webhookCustomHeaders,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
@@ -1064,18 +1074,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setWebhookNotifyOnSuccess(config.webhookNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(config.webhookNotifyOnFailure ?? true);
setWebhookNotifyOnStart(config.webhookNotifyOnStart ?? false);
setWebhookCustomHeaders(config.webhookCustomHeadersJson ?? "");
} else if (config.teamsWebhookUrl) {
setWebhookProviderType(1);
setWebhookUrl(config.teamsWebhookUrl);
setWebhookNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(config.teamsNotifyOnFailure ?? true);
setWebhookNotifyOnStart(config.teamsNotifyOnStart ?? false);
setWebhookCustomHeaders("");
} else {
setWebhookProviderType(0);
setWebhookUrl("");
setWebhookNotifyOnSuccess(true);
setWebhookNotifyOnFailure(true);
setWebhookNotifyOnStart(false);
setWebhookCustomHeaders("");
}
}, [config]);
@@ -1456,6 +1469,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
webhookNotifyOnSuccess, setWebhookNotifyOnSuccess,
webhookNotifyOnFailure, setWebhookNotifyOnFailure,
webhookNotifyOnStart, setWebhookNotifyOnStart,
webhookCustomHeaders, setWebhookCustomHeaders,
testingWebhook, testWebhookResult,
handleTestWebhook, handleSaveNotifications, handleResetNotifications,
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import SaveResetBar from "./SaveResetBar";
interface NotificationsSectionProps {
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
setWebhookNotifyOnFailure: (v: boolean) => void;
webhookNotifyOnStart: boolean;
setWebhookNotifyOnStart: (v: boolean) => void;
webhookCustomHeaders: string;
setWebhookCustomHeaders: (v: string) => void;
onTestWebhook: () => Promise<void>;
testingWebhook: boolean;
testWebhookResult: { success: boolean; message: string } | null;
@@ -21,13 +24,113 @@ interface NotificationsSectionProps {
saving: boolean;
}
const GENERIC_PROVIDER = 20;
const PROVIDERS = [
{ value: 0, label: "None (disabled)", placeholder: "" },
{ value: 1, label: "Microsoft Teams (Legacy Connector)", placeholder: "https://your-org.webhook.office.com/webhookb2/...", badge: "Deprecated", badgeColor: "bg-amber-100 text-amber-800" },
{ value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" },
{ value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." },
{ value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" },
];
type HeaderRow = { key: string; value: string };
function parseHeaderRows(json: string): HeaderRow[] {
if (!json || !json.trim()) return [];
try {
const obj = JSON.parse(json);
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
}
} catch {
// Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor.
}
return [];
}
function serializeHeaderRows(rows: HeaderRow[]): string {
const obj: Record<string, string> = {};
for (const { key, value } of rows) {
const k = key.trim();
if (k) obj[k] = value;
}
return Object.keys(obj).length > 0 ? JSON.stringify(obj) : "";
}
/**
* Key→value editor for generic-webhook custom headers. Owns row state locally and serializes
* to the JSON string the backend persists; re-syncs only on external changes (config load/reset).
*/
function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [rows, setRows] = useState<HeaderRow[]>(() => parseHeaderRows(value));
const lastSerialized = useRef(value);
useEffect(() => {
if (value !== lastSerialized.current) {
setRows(parseHeaderRows(value));
lastSerialized.current = value;
}
}, [value]);
const commit = (next: HeaderRow[]) => {
setRows(next);
const json = serializeHeaderRows(next);
lastSerialized.current = json;
onChange(json);
};
return (
<div>
<span className="text-gray-700 font-medium">Custom Headers</span>
<p className="text-sm text-gray-500 mb-2">
Optional HTTP headers sent with every request — e.g. <code className="font-mono">Authorization</code> with an API key
for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored.
</p>
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={row.key}
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)))}
placeholder="Header name"
className="block w-1/3 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
<input
type="text"
value={row.value}
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)))}
placeholder="Value"
className="block flex-1 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
<button
type="button"
onClick={() => commit(rows.filter((_, j) => j !== i))}
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
aria-label="Remove header"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
<button
type="button"
onClick={() => commit([...rows, { key: "", value: "" }])}
className="mt-2 inline-flex items-center text-sm font-medium text-sky-600 hover:text-sky-700 transition-colors"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add header
</button>
</div>
);
}
export default function NotificationsSection({
webhookProviderType,
setWebhookProviderType,
@@ -39,6 +142,8 @@ export default function NotificationsSection({
setWebhookNotifyOnFailure,
webhookNotifyOnStart,
setWebhookNotifyOnStart,
webhookCustomHeaders,
setWebhookCustomHeaders,
onTestWebhook,
testingWebhook,
testWebhookResult,
@@ -74,7 +179,13 @@ export default function NotificationsSection({
<div className="flex items-center gap-2">
<select
value={webhookProviderType}
onChange={(e) => setWebhookProviderType(Number(e.target.value))}
onChange={(e) => {
const next = Number(e.target.value);
setWebhookProviderType(next);
// Custom headers are generic-only; clear them when leaving the generic provider so
// a later switch back can't revive stale auth headers and persist them to a new endpoint.
if (next !== GENERIC_PROVIDER) setWebhookCustomHeaders("");
}}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
>
{PROVIDERS.map((p) => (
@@ -111,6 +222,8 @@ export default function NotificationsSection({
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
: webhookProviderType === 10
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
: webhookProviderType === GENERIC_PROVIDER
? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal."
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
</p>
<div className="flex items-center gap-2">
@@ -131,6 +244,11 @@ export default function NotificationsSection({
</div>
)}
{/* Custom Headers (generic provider only) */}
{webhookProviderType === GENERIC_PROVIDER && (
<WebhookHeaderEditor value={webhookCustomHeaders} onChange={setWebhookCustomHeaders} />
)}
{/* Notify on Start */}
{webhookProviderType !== 0 && (
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${isActive ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
@@ -11,6 +11,7 @@ export function SectionNotifications() {
webhookNotifyOnSuccess, setWebhookNotifyOnSuccess,
webhookNotifyOnFailure, setWebhookNotifyOnFailure,
webhookNotifyOnStart, setWebhookNotifyOnStart,
webhookCustomHeaders, setWebhookCustomHeaders,
handleTestWebhook, testingWebhook, testWebhookResult,
handleSaveNotifications, handleResetNotifications,
savingSection,
@@ -30,6 +31,8 @@ export function SectionNotifications() {
setWebhookNotifyOnFailure={setWebhookNotifyOnFailure}
webhookNotifyOnStart={webhookNotifyOnStart}
setWebhookNotifyOnStart={setWebhookNotifyOnStart}
webhookCustomHeaders={webhookCustomHeaders}
setWebhookCustomHeaders={setWebhookCustomHeaders}
onTestWebhook={handleTestWebhook}
testingWebhook={testingWebhook}
testWebhookResult={testWebhookResult}
@@ -43,6 +43,7 @@ export interface TenantConfiguration {
webhookNotifyOnFailure?: boolean;
webhookNotifyOnHardwareRejection?: boolean;
webhookNotifyOnStart?: boolean;
webhookCustomHeadersJson?: string;
// SLA targets
slaTargetSuccessRate?: number;
slaTargetMaxDurationMinutes?: number;