mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
d763a885e2
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad. Ein neuer Provider haette beide anfassen muessen. Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider — WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST. SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession, ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification). SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern. Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb ist das Gate eine Server-Regel, keine versteckte Dropdown-Option: ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann. Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft. Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList / RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin, statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson + GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload, NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0). Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar, damit ein Speichern ihn nicht still verwirft. Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub, der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden hiess also: das Sales-Team bekommt auch jede Security-Regel. Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und vereinigt ihre Ziele. Rueckwaertskompatibel ohne Migrationsjob: - Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet. - GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert. Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher wegdriftet. - Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel traegt, also verhaelt sich am Deploy-Tag nichts anders. - Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel, deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles. Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr geschrieben — aufraeumen erst, wenn produktiv Channels stehen. Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die auch die Tenant-Liste passiert. Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen. Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine Webhook-URL in den falschen Provider-Slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend): TenantTrialStarted + Event-Payload optional pro Regel Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event. RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still, ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null) ist ein Downgrade und bleibt bei TenantPlanDowngraded. Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst: ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen, und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das Informationsniveau aller bestehenden Regeln bleibt damit unveraendert. ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln. Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte <= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein stabiler Schluessel zum Verzweigen. Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung, schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben ungetestet — Suite-Konvention ist, die reinen Kerne zu testen. Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md + docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events Query-Surface), und ein Teil-Commit darin haette es mitgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies The ops-events read could only narrow by category and date window, so answering "did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and filtering it client-side — and for the MCP tool that client is a language model, so the question cost a category-sized response in model context. Ops-events filter surface (mirrors the audit-log filter pattern one-for-one): - OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters + pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters -> OData clauses). BOTH filter builders call the appender — the single-category / unpaged one and the per-partition fan-out builder that adds the RowKey bound — so "no category named" cannot silently return unfiltered rows. - minSeverity expands to an OR-set, not a range: Table Storage compares strings lexicographically and the severity names sort Critical < Error < Info < Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half. The ladder now lives in OpsEventSeverity.Rank, shared with OpsAlertDispatchService, so "Warning and above" in a read means what it meant when the alert rules fired. - severity is normalised onto the canonical vocabulary and an unknown value is a 400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an empty page that reads exactly like "no such events happened". eventType stays verbatim — stored rows outlive the vocabulary, so a retired type must remain searchable in the historical window. - Every filter value is folded into the continuation fingerprint and echoed on nextLink; extras are appended AFTER the pre-existing category/tenantId discriminators and skipped when empty, so tokens minted before the filters existed still fingerprint identically. The tenantId drill deliberately stays in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing). - MCP get_ops_events gains eventType / severity / minSeverity plus a `days` shorthand resolved client-side to a concrete dateFrom (a backend-side "last N days" would re-resolve "now" on every page and blow the token fingerprint); an explicit bound always wins over the shorthand. Fixed alongside: the paged cross-category fan-out skipped Platform (Azure Monitor alerts) because its category list had drifted from OpsEventCategory — written from day one, invisible to every paged reader, still visible on the unpaged full-table path. The list is now OpsEventCategory.All plus a reflection coverage test. That drift had a structural cause, so the same round closes it for the MCP as a whole: wire TYPES reached the MCP from day one, but vocabularies did not — types are erased at runtime and z.enum() needs values, so every tool schema hand-typed its own copy with nothing to compare against. The copies had drifted: search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete — both real stored statuses), both event readers offered 4 of 6 severities (no Debug, no Trace), get_ops_events named 6 of 7 ops categories. - The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) from the manifest sections listed in MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops vocabularies. Freshness is pinned twice — byte-equality against a fresh codegen run in the MCP suite, and the shared-manifests-in-sync CI job. - Tool schemas derive their z.enum() from it and descriptions interpolate the list instead of spelling it. - vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..]) must sit on a baseline naming why it has no backend owner (MCP-local knobs, plus rule type / connection type / CVE risk, which have no C# constants class yet). A new hand-typed list fails, and so does a stale baseline entry. - OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the OpsEventService write sites use the constants (a raw literal now fails OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection), the manifest exports them, and get_resource(name="ops_event_types") serves categories + severities + types to the model from the generated copy. Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent. Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an older backend gets silently unfiltered rows back and presents them as filtered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: register the ops notification channels doc in the index and log The concept doc landed with the feature commits; its index entry and log entry were still uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
7.0 KiB
C#
163 lines
7.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Shared.Pagination;
|
|
|
|
namespace AutopilotMonitor.Shared.DataAccess
|
|
{
|
|
/// <summary>
|
|
/// Repository for operational events (OpsEvents table).
|
|
/// Stores vital infrastructure events visible to Global Admins in the Ops dashboard.
|
|
/// </summary>
|
|
public interface IOpsEventRepository
|
|
{
|
|
Task SaveOpsEventAsync(OpsEventEntry entry);
|
|
|
|
/// <summary>
|
|
/// Returns all matching ops events in the given UTC window. <paramref name="category"/>
|
|
/// is optional — when provided, scopes to a single PartitionKey for an
|
|
/// indexed lookup. Sorted newest-first. No row cap; for unbounded
|
|
/// windows on busy installations, prefer <see cref="GetOpsEventsPageAsync"/>.
|
|
/// <paramref name="filters"/> narrows on non-key columns server-side.
|
|
/// </summary>
|
|
Task<List<OpsEventEntry>> GetOpsEventsAsync(
|
|
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null,
|
|
OpsEventQueryFilters? filters = null);
|
|
|
|
/// <summary>
|
|
/// Reads a single page of ops events. The returned <see cref="RawPage{T}"/>
|
|
/// carries the underlying store's opaque continuation token; <c>null</c>
|
|
/// when this page was the last. Items in each page are sorted
|
|
/// newest-first.
|
|
/// </summary>
|
|
Task<RawPage<OpsEventEntry>> GetOpsEventsPageAsync(
|
|
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
|
OpsEventQueryFilters? filters = null);
|
|
|
|
Task<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Categories for operational events.
|
|
/// </summary>
|
|
public static class OpsEventCategory
|
|
{
|
|
public const string Consent = "Consent";
|
|
public const string Maintenance = "Maintenance";
|
|
public const string Security = "Security";
|
|
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>
|
|
/// Every category = every OpsEvents PartitionKey. The cross-category paged read fans out
|
|
/// over exactly this list, so a category missing here is INVISIBLE to every paged reader
|
|
/// (MCP, the ops dashboard) while still being written — Platform was missing that way.
|
|
/// OpsEventCategoryCoverageTests reflects over the constants above and fails if a new one
|
|
/// is not listed here.
|
|
/// </summary>
|
|
public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Severity levels for operational events.
|
|
/// </summary>
|
|
public static class OpsEventSeverity
|
|
{
|
|
public const string Info = "Info";
|
|
public const string Warning = "Warning";
|
|
public const string Error = "Error";
|
|
public const string Critical = "Critical";
|
|
|
|
/// <summary>Canonical severities, ascending by <see cref="Rank"/>.</summary>
|
|
public static readonly string[] All = { Info, Warning, Error, Critical };
|
|
|
|
/// <summary>
|
|
/// Ordinal severity rank (Info=0 … Critical=3), -1 for anything outside the vocabulary.
|
|
/// Case-SENSITIVE on purpose: written severities always come from the constants above, and
|
|
/// the ops-alert dispatch has always compared them verbatim. Query input goes through
|
|
/// <see cref="TryNormalize"/> first.
|
|
/// </summary>
|
|
public static int Rank(string? severity) => severity switch
|
|
{
|
|
Info => 0,
|
|
Warning => 1,
|
|
Error => 2,
|
|
Critical => 3,
|
|
_ => -1,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Maps caller-supplied text ("warning", "ERROR") onto the canonical constant. Returns false
|
|
/// for anything outside the vocabulary so an endpoint can 400 instead of silently returning
|
|
/// an empty page — Table Storage <c>eq</c> is case-sensitive, so an unnormalized "warning"
|
|
/// would match nothing at all.
|
|
/// </summary>
|
|
public static bool TryNormalize(string? value, out string canonical)
|
|
{
|
|
foreach (var s in All)
|
|
{
|
|
if (string.Equals(s, value, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
canonical = s;
|
|
return true;
|
|
}
|
|
}
|
|
canonical = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Canonical severities at or above <paramref name="minSeverity"/> (empty for an unknown value).</summary>
|
|
public static IReadOnlyList<string> AtOrAbove(string minSeverity)
|
|
{
|
|
var min = Rank(minSeverity);
|
|
if (min < 0) return new string[0];
|
|
var result = new List<string>();
|
|
foreach (var s in All)
|
|
{
|
|
if (Rank(s) >= min) result.Add(s);
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional exact-match field filters for the ops-events reads. Every field maps to a
|
|
/// SERVER-SIDE OData clause (see TableOpsEventRepository.BuildFilter) — the point is that the
|
|
/// store returns only matching rows, so an operator asking for one event type does not pull a
|
|
/// whole category over the wire. Mirrors <c>AuditLogQueryFilters</c>.
|
|
/// </summary>
|
|
public class OpsEventQueryFilters
|
|
{
|
|
/// <summary>Exact match on the <c>EventType</c> column (e.g. "AgentEmergencyBreak"). Case-sensitive — Table Storage cannot fold case.</summary>
|
|
public string? EventType { get; set; }
|
|
|
|
/// <summary>Exact match on the <c>Severity</c> column. Callers should pass a canonical <see cref="OpsEventSeverity"/> value.</summary>
|
|
public string? Severity { get; set; }
|
|
|
|
/// <summary>Threshold match: every severity at or above this one (Info < Warning < Error < Critical). Same vocabulary the ops-alert rules use.</summary>
|
|
public string? MinSeverity { get; set; }
|
|
|
|
/// <summary>True when no filter field is set — callers can skip the filter plumbing entirely.</summary>
|
|
public bool IsEmpty =>
|
|
string.IsNullOrEmpty(EventType) &&
|
|
string.IsNullOrEmpty(Severity) &&
|
|
string.IsNullOrEmpty(MinSeverity);
|
|
}
|
|
|
|
public class OpsEventEntry
|
|
{
|
|
public string Id { get; set; } = string.Empty;
|
|
public string Category { get; set; } = string.Empty;
|
|
public string EventType { get; set; } = string.Empty;
|
|
public string Severity { get; set; } = OpsEventSeverity.Info;
|
|
public string? TenantId { get; set; }
|
|
public string? UserId { get; set; }
|
|
public string Message { get; set; } = string.Empty;
|
|
public string? Details { get; set; }
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
}
|