mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
40d88ef1e0
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only - One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send - The channel also gates session-watch completions and the device test; paired devices stay listed while it is off - Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in - SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact - The legacy single webhook accepts URL webhook providers only - Portal: Conditional Access hint in the channel editor, neutral role texts - Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates" - Privacy, about and FAQ describe push notifications Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
14 KiB
C#
305 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace AutopilotMonitor.Shared.Models.Notifications
|
|
{
|
|
/// <summary>
|
|
/// A named outbound notification channel (Teams / Slack / generic JSON webhook) configured
|
|
/// per tenant. Stored as a JSON array in <c>TenantConfiguration.NotificationChannelsJson</c>
|
|
/// (camelCase contract shared with the web UI).
|
|
/// <para>
|
|
/// Routing granularity: enrollment/SLA events and What's new product updates are opted into
|
|
/// per channel via the <c>NotifyOn*</c> toggles below; analyze rules reference a channel by <see cref="Id"/>
|
|
/// (rule-level notify, see <c>RuleState.NotifyChannelIds</c>). A channel with all toggles
|
|
/// off that is only referenced by rules acts as a dedicated "rules-only" channel.
|
|
/// </para>
|
|
/// </summary>
|
|
public class NotificationChannel
|
|
{
|
|
/// <summary>
|
|
/// Hard cap on channels per tenant. Excess entries are ignored at parse time so a
|
|
/// hand-crafted oversized JSON blob cannot fan a single event out to arbitrarily
|
|
/// many endpoints.
|
|
/// </summary>
|
|
public const int MaxChannelsPerTenant = 10;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Stable channel identifier (GUID string, generated by the web UI on creation).
|
|
/// Rules reference channels by this id; secret restore-on-save matches by this id.
|
|
/// </summary>
|
|
public string Id { get; set; } = default!;
|
|
|
|
/// <summary>Display name, e.g. "Service Desk" or "Team Slack".</summary>
|
|
public string Name { get; set; } = default!;
|
|
|
|
/// <summary>
|
|
/// Provider type (int form of <see cref="WebhookProviderType"/>):
|
|
/// 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord,
|
|
/// 40=Telegram (Global-Admin only — see the enum member), 50=Push (paired devices, no
|
|
/// destination field; open to tenant Admins, at most one Push channel per scope),
|
|
/// 60=Email (Global-Admin only).
|
|
/// </summary>
|
|
public int ProviderType { get; set; }
|
|
|
|
/// <summary>
|
|
/// Destination (secret — redacted for read-only viewers). For every webhook provider this
|
|
/// is the endpoint URL; for <see cref="WebhookProviderType.Telegram"/> it is the CHAT ID
|
|
/// instead, because the endpoint (the bot) is platform-owned. Deliberately one field: the
|
|
/// redaction and restore-by-id machinery already covers it, so Telegram needs no new
|
|
/// secret plumbing. Empty for <see cref="WebhookProviderType.Push"/> (see
|
|
/// <see cref="HasDestination"/>).
|
|
/// </summary>
|
|
public string? Url { get; set; }
|
|
|
|
/// <summary>
|
|
/// True when the channel can be dispatched to: a Push channel always (its recipients are
|
|
/// the scope's paired devices, resolved at send time), every other provider only with a
|
|
/// non-blank <see cref="Url"/>. The ONE rule behind the "no destination configured" skips
|
|
/// in the dispatcher, the test endpoints and the What's-new digest — a provider-aware
|
|
/// copy at each site is exactly how one of them would keep dropping Push channels.
|
|
/// </summary>
|
|
public bool HasDestination()
|
|
=> ProviderType == (int)WebhookProviderType.Push || !string.IsNullOrWhiteSpace(Url);
|
|
|
|
/// <summary>
|
|
/// Providers a tenant admin may not create or retarget: they deliver to a destination the
|
|
/// caller names (a chat, mail recipients) through PLATFORM-owned infrastructure (the Telegram
|
|
/// bot; the platform's e-mail sender and identity). Enforced server-side in
|
|
/// TenantConfigValidation, mirrored by the <c>gaOnly</c> flag in the web ChannelEditor. Push
|
|
/// sends through the platform too, but is open to tenant Admins: its only recipients are the
|
|
/// scope's own paired devices, and a scope holds at most one Push channel.
|
|
/// </summary>
|
|
public static bool IsGlobalAdminOnlyProvider(int providerType)
|
|
=> providerType == (int)WebhookProviderType.Telegram
|
|
|| providerType == (int)WebhookProviderType.Email;
|
|
|
|
/// <summary>
|
|
/// True for a provider whose destination is a caller-supplied webhook URL, rendered and posted
|
|
/// through the SSRF-guarded webhook path: every supported provider except Telegram, Push and
|
|
/// E-mail. The legacy single-webhook field accepts only these.
|
|
/// </summary>
|
|
public static bool IsWebhookUrlProvider(int providerType)
|
|
=> IsSupportedProviderType(providerType)
|
|
&& providerType != (int)WebhookProviderType.Telegram
|
|
&& providerType != (int)WebhookProviderType.Push
|
|
&& providerType != (int)WebhookProviderType.Email;
|
|
|
|
/// <summary>
|
|
/// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }), applied
|
|
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
|
|
/// </summary>
|
|
public string? CustomHeadersJson { get; set; }
|
|
|
|
/// <summary>
|
|
/// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied
|
|
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
|
|
/// </summary>
|
|
public string? SigningSecret { get; set; }
|
|
|
|
/// <summary>Master switch: a disabled channel never receives anything.</summary>
|
|
public bool Enabled { get; set; } = true;
|
|
|
|
// ── Per-channel event subscriptions (enrollment + SLA) ───────────────
|
|
// Rule-fired alerts are NOT toggled here — rules target channels by id.
|
|
|
|
/// <summary>Receive enrollment-started notifications (opt-in).</summary>
|
|
public bool NotifyOnStart { get; set; }
|
|
|
|
/// <summary>Receive enrollment-success / white-glove-success notifications.</summary>
|
|
public bool NotifyOnSuccess { get; set; }
|
|
|
|
/// <summary>Receive enrollment-failure / ESP-failure notifications.</summary>
|
|
public bool NotifyOnFailure { get; set; }
|
|
|
|
/// <summary>Receive hardware-whitelist-rejection notifications (opt-in).</summary>
|
|
public bool NotifyOnHardwareRejection { get; set; }
|
|
|
|
/// <summary>
|
|
/// Receive SLA notifications (breach / resolved / consecutive failures). Which breach
|
|
/// types are evaluated at all remains governed by the tenant-level <c>SlaNotifyOn*</c>
|
|
/// flags — this toggle only decides whether THIS channel is a recipient.
|
|
/// </summary>
|
|
public bool NotifyOnSlaEvents { get; set; }
|
|
|
|
/// <summary>
|
|
/// Receive "What's new" product updates: whenever new platform / agent changelog entries
|
|
/// go live in the portal's What's new panel, one digest message per batch is sent to this
|
|
/// channel (see <c>WhatsNewNotificationService</c>). Opt-in; the legacy synthesized channel
|
|
/// never receives them.
|
|
/// </summary>
|
|
public bool NotifyOnWhatsNew { get; set; }
|
|
|
|
/// <summary>
|
|
/// Parses <see cref="CustomHeadersJson"/> into request headers for the generic-webhook
|
|
/// dispatcher. Empty unless <see cref="ProviderType"/> is GenericJson. Never throws.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> GetCustomHeaders()
|
|
{
|
|
if (ProviderType != (int)WebhookProviderType.GenericJson)
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return WebhookHeaderParser.Parse(CustomHeadersJson);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when
|
|
/// the channel is not GenericJson or no secret is configured.
|
|
/// </summary>
|
|
public string? GetSigningSecret()
|
|
{
|
|
if (ProviderType != (int)WebhookProviderType.GenericJson)
|
|
return null;
|
|
|
|
return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True for a provider a channel can be dispatched to: a defined <see cref="WebhookProviderType"/>
|
|
/// other than <see cref="WebhookProviderType.None"/>. The one rule for parsing, save-time
|
|
/// validation and the legacy single-webhook field, so a retired value never becomes a channel.
|
|
/// </summary>
|
|
public static bool IsSupportedProviderType(int providerType)
|
|
=> providerType != (int)WebhookProviderType.None
|
|
&& Enum.IsDefined(typeof(WebhookProviderType), providerType);
|
|
|
|
/// <summary>
|
|
/// Deserializes a channel list from its JSON storage form. Fail-soft: null/blank/malformed
|
|
/// input yields an empty list (dispatch then simply has no channels to send to). Entries
|
|
/// without an id or with an unknown provider type are dropped; the list is capped at
|
|
/// <see cref="MaxChannelsPerTenant"/>.
|
|
/// </summary>
|
|
public static List<NotificationChannel> ParseList(string? json)
|
|
{
|
|
var result = new List<NotificationChannel>();
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
return result;
|
|
|
|
List<NotificationChannel>? parsed;
|
|
try
|
|
{
|
|
parsed = JsonSerializer.Deserialize<List<NotificationChannel>>(json!, JsonOptions);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
if (parsed == null)
|
|
return result;
|
|
|
|
foreach (var channel in parsed)
|
|
{
|
|
if (result.Count >= MaxChannelsPerTenant)
|
|
break;
|
|
if (channel == null || string.IsNullOrWhiteSpace(channel.Id))
|
|
continue;
|
|
if (!IsSupportedProviderType(channel.ProviderType))
|
|
continue;
|
|
|
|
result.Add(channel);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Serializes a channel list back to its JSON storage form (camelCase).</summary>
|
|
public static string SerializeList(IEnumerable<NotificationChannel> channels)
|
|
=> JsonSerializer.Serialize(channels, JsonOptions);
|
|
|
|
/// <summary>
|
|
/// Redacts the secret fields (destination, custom headers, signing secret) of every channel
|
|
/// while keeping the list structure (ids, names, toggles) readable, so a read-only viewer
|
|
/// still sees WHICH channels exist. Non-empty but unparseable JSON falls back to
|
|
/// whole-string redaction — never leak a blob we could not inspect.
|
|
/// <para>
|
|
/// Shared by the tenant channel list (<c>TenantConfiguration</c>) and the platform ops
|
|
/// channel list (<c>AdminConfiguration</c>): both are served to read-only roles, and a
|
|
/// second copy of this logic is exactly how one of them would grow a leak.
|
|
/// </para>
|
|
/// </summary>
|
|
public static string RedactList(string? json)
|
|
{
|
|
if (string.IsNullOrEmpty(json))
|
|
return json ?? string.Empty;
|
|
|
|
var channels = ParseList(json);
|
|
if (channels.Count == 0)
|
|
return Constants.RedactedSecretPlaceholder;
|
|
|
|
foreach (var channel in channels)
|
|
{
|
|
channel.Url = RedactNullable(channel.Url);
|
|
channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson);
|
|
channel.SigningSecret = RedactNullable(channel.SigningSecret);
|
|
}
|
|
|
|
return SerializeList(channels);
|
|
|
|
// Unlike the top-level config fields (non-nullable strings), channel secrets are
|
|
// string? — an unset field must stay null, not become "".
|
|
static string? RedactNullable(string? value)
|
|
=> string.IsNullOrEmpty(value) ? value : Constants.RedactedSecretPlaceholder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores channel secrets that came back as the redaction sentinel, matching by
|
|
/// <see cref="Id"/> against <paramref name="existingJson"/>. Defense-in-depth for the
|
|
/// case where a redacted read-only view is round-tripped on a save: without this, the
|
|
/// literal "***REDACTED***" would be persisted as the destination.
|
|
/// <para>
|
|
/// Returns the candidate JSON unchanged when it carries no sentinel. A sentinel in place
|
|
/// of the WHOLE list (unparseable-blob redaction) means "keep what is stored". Sentinels
|
|
/// that cannot be matched to a stored channel are left in place — an unresolvable
|
|
/// placeholder destination simply fails validation, which is louder than silently
|
|
/// substituting somebody else's endpoint.
|
|
/// </para>
|
|
/// </summary>
|
|
public static string RestoreRedactedList(string? candidateJson, string? existingJson)
|
|
{
|
|
if (candidateJson == null)
|
|
return string.Empty;
|
|
|
|
if (candidateJson == Constants.RedactedSecretPlaceholder)
|
|
return existingJson ?? string.Empty;
|
|
|
|
if (candidateJson.Length == 0
|
|
|| candidateJson.IndexOf(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal) < 0)
|
|
{
|
|
return candidateJson;
|
|
}
|
|
|
|
var incoming = ParseList(candidateJson);
|
|
if (incoming.Count == 0)
|
|
return candidateJson;
|
|
|
|
var existingById = new Dictionary<string, NotificationChannel>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var stored in ParseList(existingJson))
|
|
existingById[stored.Id] = stored;
|
|
|
|
foreach (var channel in incoming)
|
|
{
|
|
if (!existingById.TryGetValue(channel.Id, out var stored))
|
|
continue;
|
|
|
|
if (channel.Url == Constants.RedactedSecretPlaceholder)
|
|
channel.Url = stored.Url;
|
|
if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder)
|
|
channel.CustomHeadersJson = stored.CustomHeadersJson;
|
|
if (channel.SigningSecret == Constants.RedactedSecretPlaceholder)
|
|
channel.SigningSecret = stored.SigningSecret;
|
|
}
|
|
|
|
return SerializeList(incoming);
|
|
}
|
|
}
|
|
}
|