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>
505 lines
27 KiB
C#
505 lines
27 KiB
C#
using AutopilotMonitor.Shared;
|
||
using System.Net;
|
||
using AutopilotMonitor.Functions.Helpers;
|
||
using AutopilotMonitor.Functions.Security;
|
||
using AutopilotMonitor.Functions.Services;
|
||
using AutopilotMonitor.Functions.Services.Deletion;
|
||
using AutopilotMonitor.Functions.Services.Notifications;
|
||
using AutopilotMonitor.Shared.DataAccess;
|
||
using AutopilotMonitor.Shared.Models;
|
||
using AutopilotMonitor.Shared.Models.Notifications;
|
||
using Microsoft.Azure.Functions.Worker;
|
||
using Microsoft.Azure.Functions.Worker.Http;
|
||
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
|
||
using Microsoft.Extensions.Logging;
|
||
using Newtonsoft.Json;
|
||
|
||
namespace AutopilotMonitor.Functions.Functions.Sessions
|
||
{
|
||
public class RegisterSessionFunction
|
||
{
|
||
// Set to false to suppress the start notification on WhiteGlove Part 2 resume
|
||
// (user-driven phase after a pre-provisioned device is delivered to the end user).
|
||
// The fresh-session start notification is unaffected.
|
||
private const bool NotifyOnWhiteGloveResume = true;
|
||
|
||
private readonly ILogger<RegisterSessionFunction> _logger;
|
||
private readonly ISessionRepository _sessionRepo;
|
||
private readonly IMetricsRepository _metricsRepo;
|
||
private readonly TenantConfigurationService _configService;
|
||
private readonly RateLimitService _rateLimitService;
|
||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
|
||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||
private readonly NotificationChannelDispatcher _channelDispatcher;
|
||
private readonly SessionDeletionGuard _deletionGuard;
|
||
private readonly AdminConfigurationService _adminConfigService;
|
||
private readonly SessionOwnerBindingObserver _ownerBinding;
|
||
private readonly OpsEventService _opsEvents;
|
||
|
||
public RegisterSessionFunction(
|
||
ILogger<RegisterSessionFunction> logger,
|
||
ISessionRepository sessionRepo,
|
||
IMetricsRepository metricsRepo,
|
||
TenantConfigurationService configService,
|
||
AdminConfigurationService adminConfigService,
|
||
RateLimitService rateLimitService,
|
||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||
DeviceAssociationValidator deviceAssociationValidator,
|
||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||
BootstrapSessionService bootstrapSessionService,
|
||
NotificationChannelDispatcher channelDispatcher,
|
||
SessionDeletionGuard deletionGuard,
|
||
SessionOwnerBindingObserver ownerBinding,
|
||
OpsEventService opsEvents)
|
||
{
|
||
_logger = logger;
|
||
_sessionRepo = sessionRepo;
|
||
_metricsRepo = metricsRepo;
|
||
_configService = configService;
|
||
_adminConfigService = adminConfigService;
|
||
_rateLimitService = rateLimitService;
|
||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||
_deviceAssociationValidator = deviceAssociationValidator;
|
||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||
_bootstrapSessionService = bootstrapSessionService;
|
||
_channelDispatcher = channelDispatcher;
|
||
_deletionGuard = deletionGuard;
|
||
_ownerBinding = ownerBinding;
|
||
_opsEvents = opsEvents;
|
||
}
|
||
|
||
[Function("RegisterSession")]
|
||
public async Task<RegisterSessionOutput> Run(
|
||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/register-session")] HttpRequestData req)
|
||
{
|
||
_logger.LogInformation("RegisterSession function processing request");
|
||
|
||
try
|
||
{
|
||
// Parse request
|
||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||
&& contentLength > 1_048_576) // 1 MB limit
|
||
{
|
||
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Request body too large");
|
||
return new RegisterSessionOutput { HttpResponse = errorResponse };
|
||
}
|
||
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
|
||
var request = JsonConvert.DeserializeObject<RegisterSessionRequest>(requestBody);
|
||
|
||
if (request?.Registration == null)
|
||
{
|
||
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Invalid request payload");
|
||
return new RegisterSessionOutput { HttpResponse = errorResponse };
|
||
}
|
||
|
||
var registration = request.Registration;
|
||
|
||
// Validate request security (certificate, rate limit, hardware whitelist)
|
||
var (validation, errorResponse2) = await req.ValidateSecurityAsync(
|
||
registration.TenantId,
|
||
_configService,
|
||
_adminConfigService,
|
||
_rateLimitService,
|
||
_autopilotDeviceValidator,
|
||
_corporateIdentifierValidator,
|
||
_logger,
|
||
registration.SessionId,
|
||
bootstrapSessionService: _bootstrapSessionService,
|
||
deviceAssociationValidator: _deviceAssociationValidator,
|
||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
|
||
);
|
||
|
||
if (errorResponse2 != null)
|
||
{
|
||
return new RegisterSessionOutput { HttpResponse = errorResponse2 };
|
||
}
|
||
|
||
return await ProcessRegisterAsync(req, registration, validation);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error registering session");
|
||
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
|
||
return new RegisterSessionOutput { HttpResponse = errorResponse };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Core registration logic: store session, emit SignalR notifications.
|
||
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
|
||
/// </summary>
|
||
internal async Task<RegisterSessionOutput> ProcessRegisterAsync(HttpRequestData req, SessionRegistration registration, SecurityValidationResult validation)
|
||
{
|
||
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
|
||
|
||
// Server-populated: record which device-validation path accepted this request
|
||
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap) so the
|
||
// session row shows HOW the backend admitted the device. Overwrites any
|
||
// agent-sent value unconditionally — this is the server's verdict, not input.
|
||
registration.ValidatedBy = validation.ValidatedBy;
|
||
|
||
// Device-supplied session anchor: clamp before ANY consumer sees it. StartedAt drives the
|
||
// SessionsIndex RowKey (list order), the retention filter ("StartedAt lt cutoff"), the
|
||
// TotalToday window and the supersede-predecessor comparison below — a far-future value
|
||
// would pin the row at the head of every list, inflate today's counters forever and
|
||
// never become a retention candidate. Same skew policy as event timestamps
|
||
// (EventTimestampValidator: +24h / -168h, clamped to server time, not rejected).
|
||
ClampStartedAt(registration, DateTime.UtcNow, _logger);
|
||
|
||
// Cascade-delete guard (Codex F2/F3 + bootstrap follow-up): live HERE in the shared
|
||
// core so BOTH the cert-auth /agent/register-session entry AND the bootstrap wrapper
|
||
// (BootstrapRegisterSessionFunction) hit it before the StoreSessionAsync below.
|
||
// Refuses with 410 Gone on lock states (Preparing/Queued/Running/Poisoned) and on a
|
||
// fresh tombstone marker — without this gate the bootstrap path could still mutate a
|
||
// mid-cascade row or Add-revive a session past the tombstone.
|
||
Azure.Data.Tables.TableEntity? guardSessionRow;
|
||
try
|
||
{
|
||
guardSessionRow = await _deletionGuard.EnsureWritableAndGetRowAsync(
|
||
registration.TenantId, registration.SessionId, "RegisterSession");
|
||
}
|
||
catch (SessionDeletionLockedException locked)
|
||
{
|
||
_logger.LogInformation(
|
||
"RegisterSession refused: cascade in flight or recent tombstone — tenant={Tenant} session={Session} state={State} manifestId={ManifestId}",
|
||
registration.TenantId, registration.SessionId, locked.CurrentState, locked.ManifestId);
|
||
return new RegisterSessionOutput
|
||
{
|
||
HttpResponse = await WriteSessionLockedAsync(req, locked),
|
||
};
|
||
}
|
||
|
||
// Pre-read the session row so we can distinguish three lifecycle entries:
|
||
// - existing == null → first-time registration (fresh enrollment)
|
||
// - existing.Pending → WhiteGlove Part 2 resume (user-driven phase)
|
||
// - everything else → agent restart / terminal re-register (no start signal)
|
||
// Used below to scope the opt-in "enrollment started" webhook to real start events.
|
||
var preExistingSession = await _sessionRepo.GetSessionAsync(registration.TenantId, registration.SessionId);
|
||
bool isFreshRegistration = preExistingSession == null;
|
||
bool isWhiteGloveResume = preExistingSession?.Status == SessionStatus.Pending;
|
||
|
||
// SESSION-OWNER-BINDING-SHADOW: compare the caller's validated identity against the
|
||
// owner already on the row (reusing the guard's read). Stage 1 never refuses — the
|
||
// decision only records the outcome and yields the owner to stamp (fresh bind,
|
||
// legacy claim, cert rotation, bootstrap→cert handoff). Enforcement (stage 2) will turn
|
||
// a would-reject here into 403 + AgentErrorCodes.SessionOwnerMismatch, on which the
|
||
// agent rotates its session id.
|
||
var ownerDecision = _ownerBinding.Observe(
|
||
req, registration.TenantId, registration.SessionId, guardSessionRow, validation, "agent/register-session");
|
||
|
||
// Store session in Azure Table Storage. The sessionId → tenantId lookup claim inside is
|
||
// first-writer-wins: a sessionId already owned by another tenant is refused with 409 so
|
||
// no tenant can poison the cross-tenant resolver (SessionTenantLookup) that Global
|
||
// Admin/Reader session reads and GA writes trust.
|
||
bool stored;
|
||
try
|
||
{
|
||
stored = await _sessionRepo.StoreSessionAsync(registration, ownerDecision.OwnerToStamp);
|
||
}
|
||
catch (SessionTenantConflictException conflict)
|
||
{
|
||
_logger.LogWarning(
|
||
"RegisterSession refused: session {SessionId} is owned by another tenant — requested tenant={TenantId} thumbprint={Thumbprint}",
|
||
registration.SessionId, registration.TenantId, validation.CertificateThumbprint);
|
||
_ = _opsEvents.RecordSessionTenantConflictAsync(
|
||
registration.TenantId, registration.SessionId, conflict.OwningTenantId,
|
||
validation.CertificateThumbprint, registration.AgentVersion, "agent/register-session")
|
||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordSessionTenantConflictAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||
return new RegisterSessionOutput { HttpResponse = await WriteSessionTenantConflictAsync(req) };
|
||
}
|
||
|
||
if (!stored)
|
||
{
|
||
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Failed to store session");
|
||
return new RegisterSessionOutput { HttpResponse = errorResponse };
|
||
}
|
||
|
||
// Enrollment counters (fire-and-forget, non-blocking). Only first-time registrations
|
||
// count: agent restarts and WhiteGlove Part 2 resumes re-register the same session and
|
||
// must not inflate either counter. The platform stat previously incremented on every
|
||
// re-register — corrected daily by the recompute, but visibly overcounting in between.
|
||
// The per-tenant counter has no corrective recompute at all (only a raise-only floor),
|
||
// so gating on fresh registrations is load-bearing there.
|
||
if (isFreshRegistration)
|
||
{
|
||
_ = _metricsRepo.IncrementPlatformStatAsync("TotalEnrollments")
|
||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||
|
||
_ = _metricsRepo.IncrementTenantStatAsync(registration.TenantId, "TotalEnrollments")
|
||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementTenantStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||
}
|
||
|
||
// Retrieve the stored session to include full data in SignalR message
|
||
var session = await _sessionRepo.GetSessionAsync(registration.TenantId, registration.SessionId);
|
||
|
||
// Supersede pass (misclassification audit 2026-07-16): when a device registers a FRESH
|
||
// session while older non-terminal sessions of the same device still exist (e.g.
|
||
// WhiteGlove Part 2 running under a new session id leaves the Part-1 row Pending
|
||
// forever, or a wiped/re-enrolled device abandons its old InProgress run), resolve
|
||
// those predecessors as Incomplete("Superseded by ..."). One physical device can only
|
||
// ever run one enrollment. Best-effort — never blocks the registration.
|
||
var supersededMessages = isFreshRegistration
|
||
? await SupersedeOrphanedPredecessorsAsync(registration)
|
||
: Array.Empty<SignalRMessageAction>();
|
||
|
||
// AdminAction on re-register is only the portal button signal. SessionSummary.AdminMarkedAction
|
||
// is set exclusively by MarkSessionSucceededFunction / MarkSessionFailedFunction. An
|
||
// agent that restarts after its own completion must NOT receive a phantom AdminAction —
|
||
// previously inferred from "status is terminal", which applied to every agent-driven
|
||
// Succeeded/Failed session too.
|
||
string? adminAction = session?.AdminMarkedAction;
|
||
if (!string.IsNullOrEmpty(adminAction))
|
||
{
|
||
_logger.LogInformation("Session {SessionId} was admin-marked as {AdminAction} — signaling agent on re-register: AdminAction={AdminAction}",
|
||
registration.SessionId, adminAction, adminAction);
|
||
}
|
||
|
||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||
var responseData = new RegisterSessionResponse
|
||
{
|
||
SessionId = registration.SessionId,
|
||
Success = true,
|
||
Message = "Session registered successfully",
|
||
RegisteredAt = DateTime.UtcNow,
|
||
AdminAction = adminAction,
|
||
ValidatedBy = validation.ValidatedBy
|
||
};
|
||
|
||
await response.WriteAsJsonAsync(responseData);
|
||
|
||
// Send SignalR notification for new session registration
|
||
// This is sent to BOTH tenant-specific group AND global-admins group
|
||
// so Global Admins can see new sessions from all tenants without being
|
||
// flooded with every single event update
|
||
var messagePayload = new {
|
||
sessionId = registration.SessionId,
|
||
tenantId = registration.TenantId,
|
||
session = session
|
||
};
|
||
|
||
// 1. Tenant-specific notification (normal users)
|
||
var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession)
|
||
{
|
||
GroupName = $"tenant-{registration.TenantId}",
|
||
Arguments = new[] { messagePayload }
|
||
};
|
||
|
||
// 2. Global Admins notification (cross-tenant visibility)
|
||
var globalAdminMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession)
|
||
{
|
||
GroupName = "global-admins",
|
||
Arguments = new[] { messagePayload }
|
||
};
|
||
|
||
// Opt-in "Enrollment Started" webhook (Teams/Slack). Fired once per real start:
|
||
// fresh registration OR WhiteGlove Part 2 resume (suppressible via NotifyOnWhiteGloveResume).
|
||
// Agent restarts and re-registrations of terminal sessions never trigger this.
|
||
if ((isFreshRegistration || (isWhiteGloveResume && NotifyOnWhiteGloveResume))
|
||
&& session != null)
|
||
{
|
||
_ = SendStartNotificationAsync(registration.TenantId, registration.SessionId, session, isWhiteGloveResume);
|
||
}
|
||
|
||
return new RegisterSessionOutput
|
||
{
|
||
HttpResponse = response,
|
||
SignalRMessages = new[] { tenantMessage, globalAdminMessage }
|
||
.Concat(supersededMessages)
|
||
.ToArray()
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves older non-terminal sessions (Pending/InProgress/Stalled/AwaitingUser) of the
|
||
/// same device to Incomplete("Superseded by session …") when a fresh registration proves
|
||
/// the device has moved on. Returns portal-live SignalR updates for each resolved row
|
||
/// (same "newevents" sessionUpdate shape the ingest path uses). Fail-soft: any error
|
||
/// returns an empty array and the registration proceeds untouched.
|
||
/// </summary>
|
||
private async Task<SignalRMessageAction[]> SupersedeOrphanedPredecessorsAsync(SessionRegistration registration)
|
||
{
|
||
try
|
||
{
|
||
if (!Helpers.SerialNumberHeuristics.IsUsableSerialNumber(registration.SerialNumber))
|
||
return Array.Empty<SignalRMessageAction>();
|
||
|
||
var openSessions = await _sessionRepo.GetOpenSessionsForDeviceAsync(
|
||
registration.TenantId, registration.SerialNumber.Trim());
|
||
if (openSessions.Count == 0)
|
||
return Array.Empty<SignalRMessageAction>();
|
||
|
||
var messages = new List<SignalRMessageAction>();
|
||
foreach (var stale in openSessions)
|
||
{
|
||
if (string.Equals(stale.SessionId, registration.SessionId, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
// Only strictly older runs — a clock-skewed or concurrent row is left alone.
|
||
if (stale.StartedAt >= registration.StartedAt)
|
||
continue;
|
||
|
||
var reason = $"Superseded by session {registration.SessionId}: the device registered a new " +
|
||
$"enrollment session at {registration.StartedAt:yyyy-MM-dd HH:mm} UTC before this one " +
|
||
"reached a terminal state.";
|
||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||
registration.TenantId, stale.SessionId, SessionStatus.Incomplete, VerdictPaths.RegisterSuperseded,
|
||
failureReason: reason, failureSource: "superseded_by_reregistration");
|
||
if (!transitioned)
|
||
continue;
|
||
|
||
_logger.LogInformation(
|
||
"Superseded stale {StaleStatus} session {StaleSessionId} (started {StaleStartedAt:yyyy-MM-dd}) with new session {SessionId} for device serial (tenant {TenantId})",
|
||
stale.Status, stale.SessionId, stale.StartedAt, registration.SessionId, registration.TenantId);
|
||
|
||
messages.Add(new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||
{
|
||
GroupName = $"tenant-{registration.TenantId}",
|
||
Arguments = new object[] { new {
|
||
sessionId = stale.SessionId,
|
||
tenantId = registration.TenantId,
|
||
eventCount = 0,
|
||
sessionUpdate = new
|
||
{
|
||
Status = SessionStatus.Incomplete,
|
||
FailureReason = reason,
|
||
}
|
||
} }
|
||
});
|
||
}
|
||
|
||
return messages.ToArray();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex,
|
||
"Supersede pass failed for tenant {TenantId} session {SessionId}; registration proceeds",
|
||
registration.TenantId, registration.SessionId);
|
||
return Array.Empty<SignalRMessageAction>();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fire-and-forget enrollment-started webhook. Loads tenant config, respects the
|
||
/// opt-in <c>WebhookNotifyOnStart</c>/<c>TeamsNotifyOnStart</c> toggle, and dispatches
|
||
/// through the same renderer pipeline (Teams Legacy / Workflow / Slack) as success/failure.
|
||
/// </summary>
|
||
private async Task SendStartNotificationAsync(string tenantId, string sessionId, SessionSummary session, bool isResume)
|
||
{
|
||
try
|
||
{
|
||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||
var startChannels = tenantConfig.GetNotificationChannels()
|
||
.Where(c => c.Enabled && c.NotifyOnStart)
|
||
.ToList();
|
||
if (startChannels.Count == 0)
|
||
return;
|
||
|
||
var sessionUrl = Constants.PortalSessionUrl(sessionId);
|
||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||
session.DeviceName,
|
||
session.SerialNumber,
|
||
session.Manufacturer,
|
||
session.Model,
|
||
isResume: isResume,
|
||
sessionUrl: sessionUrl);
|
||
|
||
await _channelDispatcher.SendToChannelsAsync(startChannels, alert);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Fire-and-forget enrollment-started webhook failed for tenant {TenantId} session {SessionId}", tenantId, sessionId);
|
||
}
|
||
}
|
||
|
||
private async Task<HttpResponseData> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
|
||
{
|
||
var response = req.CreateResponse(statusCode);
|
||
var errorResponse = new RegisterSessionResponse
|
||
{
|
||
Success = false,
|
||
Message = message,
|
||
RegisteredAt = DateTime.UtcNow
|
||
};
|
||
await response.WriteAsJsonAsync(errorResponse);
|
||
return response;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
|
||
/// the agent treats register and telemetry uniformly when a session is being deleted.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
|
||
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
|
||
/// [utcNow − 168h, utcNow + 24h] the anchor becomes the server receive time. Runs in the
|
||
/// shared core so the cert-auth and the bootstrap entry are covered alike. A re-registration
|
||
/// of an existing session keeps the earlier stored anchor regardless (StoreSessionAsync
|
||
/// takes the minimum), so a legitimately old WhiteGlove Part-2 resume is not moved.
|
||
/// </summary>
|
||
internal static void ClampStartedAt(SessionRegistration registration, DateTime utcNow, ILogger logger)
|
||
{
|
||
var original = registration.StartedAt;
|
||
var sanitized = EventTimestampValidator.SanitizeTimestamp(original, utcNow);
|
||
if (sanitized == EventTimestampValidator.EnsureUtc(original))
|
||
{
|
||
registration.StartedAt = sanitized;
|
||
return;
|
||
}
|
||
|
||
logger.LogWarning(
|
||
"Session {SessionId} (tenant {TenantId}): registration StartedAt {OriginalStartedAt:o} is outside the plausible window around server time {UtcNow:o} — clamped to server time",
|
||
registration.SessionId, registration.TenantId, original, utcNow);
|
||
registration.StartedAt = sanitized;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 409 for a sessionId already owned by another tenant. Carries
|
||
/// <see cref="Constants.AgentErrorCodes.SessionOwnerMismatch"/> — the agent's correct reaction
|
||
/// is the same as for an owner-binding refusal: rotate the SessionId and register afresh.
|
||
/// The owning tenant is deliberately NOT disclosed to the caller.
|
||
/// </summary>
|
||
private static async Task<HttpResponseData> WriteSessionTenantConflictAsync(HttpRequestData req)
|
||
{
|
||
var response = req.CreateResponse(HttpStatusCode.Conflict);
|
||
await response.WriteAsJsonAsync(new RegisterSessionResponse
|
||
{
|
||
Success = false,
|
||
Message = "Session id is already registered to another tenant; register with a new session id.",
|
||
RegisteredAt = DateTime.UtcNow,
|
||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||
});
|
||
return response;
|
||
}
|
||
|
||
private static async Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||
{
|
||
var response = req.CreateResponse(HttpStatusCode.Gone);
|
||
await response.WriteAsJsonAsync(new RegisterSessionResponse
|
||
{
|
||
Success = false,
|
||
Message = $"Session is being deleted by an administrator (state={locked.CurrentState}); registration is rejected.",
|
||
RegisteredAt = DateTime.UtcNow,
|
||
});
|
||
return response;
|
||
}
|
||
}
|
||
|
||
public class RegisterSessionOutput
|
||
{
|
||
[HttpResult]
|
||
public HttpResponseData? HttpResponse { get; set; }
|
||
|
||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||
}
|
||
}
|