Files
AutopilotMonitor/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs
T
Oliver Kieselbach 40d88ef1e0 Push notifications for every tenant: one Push channel per scope that gates every send, app-role members equal, alerts name the tenant (D-368, D-369)
- 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>
2026-10-20 14:13:58 +02:00

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);
}
}
}