Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs
T
Oliver Kieselbach d763a885e2 Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)
* 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>
2026-09-01 16:57:41 +02:00

505 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}
}