mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
ea6a375c4b
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
658 lines
33 KiB
C#
658 lines
33 KiB
C#
using System.IO;
|
||
using System.Net;
|
||
using AutopilotMonitor.Functions.Helpers;
|
||
using AutopilotMonitor.Functions.Security;
|
||
using AutopilotMonitor.Functions.Services;
|
||
using AutopilotMonitor.Functions.Services.Deletion;
|
||
using AutopilotMonitor.Functions.Services.Indexing;
|
||
using AutopilotMonitor.Shared.DataAccess;
|
||
using AutopilotMonitor.Shared.Models;
|
||
using Microsoft.Azure.Functions.Worker;
|
||
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
|
||
using Microsoft.Azure.Functions.Worker.Http;
|
||
using Microsoft.Extensions.Logging;
|
||
using Newtonsoft.Json;
|
||
|
||
namespace AutopilotMonitor.Functions.Functions.Ingest
|
||
{
|
||
/// <summary>
|
||
/// Agent ingest endpoint. Consumes a heterogeneous batch of <see cref="TelemetryItemDto"/>s
|
||
/// (Events + Signals + DecisionTransitions in a single JSON array, gzip-compressed on
|
||
/// the wire — the <c>UseRequestDecompression</c> middleware decompresses before we
|
||
/// parse). Plan §2.7a / §M5 / M4.6.ε.
|
||
/// <para>
|
||
/// <b>Routing by <see cref="TelemetryItemDto.Kind"/>:</b>
|
||
/// <list type="bullet">
|
||
/// <item><c>Event</c> → <see cref="EventIngestProcessor"/> (rule engine, app-install
|
||
/// aggregation, SignalR, vulnerability correlation, webhooks, SLA breach, AdminAction
|
||
/// detection, ServerAction delivery)</item>
|
||
/// <item><c>Signal</c> → <see cref="ISignalRepository.StoreBatchAsync"/></item>
|
||
/// <item><c>DecisionTransition</c> → <see cref="IDecisionTransitionRepository.StoreBatchAsync"/></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Response (M4.6.ε):</b> the agent parses <c>DeviceBlocked</c>/<c>UnblockAt</c>/
|
||
/// <c>DeviceKillSignal</c>/<c>AdminAction</c>/<c>Actions</c> from the 2xx body and routes
|
||
/// kill-switches through its <c>ServerActionDispatcher</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class IngestTelemetryFunction
|
||
{
|
||
private readonly ILogger<IngestTelemetryFunction> _logger;
|
||
private readonly ISessionRepository _sessionRepo;
|
||
private readonly ISignalRepository _signalRepo;
|
||
private readonly IDecisionTransitionRepository _transitionRepo;
|
||
private readonly IIndexReconcileProducer _indexReconcileProducer;
|
||
private readonly EventIngestProcessor _eventProcessor;
|
||
private readonly TenantConfigurationService _configService;
|
||
private readonly AdminConfigurationService _adminConfigService;
|
||
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 KillSwitchEvaluator _killSwitchEvaluator;
|
||
private readonly SessionDeletionGuard _deletionGuard;
|
||
|
||
public IngestTelemetryFunction(
|
||
ILogger<IngestTelemetryFunction> logger,
|
||
ISessionRepository sessionRepo,
|
||
ISignalRepository signalRepo,
|
||
IDecisionTransitionRepository transitionRepo,
|
||
IIndexReconcileProducer indexReconcileProducer,
|
||
EventIngestProcessor eventProcessor,
|
||
TenantConfigurationService configService,
|
||
AdminConfigurationService adminConfigService,
|
||
RateLimitService rateLimitService,
|
||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||
DeviceAssociationValidator deviceAssociationValidator,
|
||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
|
||
BootstrapSessionService bootstrapSessionService,
|
||
KillSwitchEvaluator killSwitchEvaluator,
|
||
SessionDeletionGuard deletionGuard)
|
||
{
|
||
_logger = logger;
|
||
_sessionRepo = sessionRepo;
|
||
_signalRepo = signalRepo;
|
||
_transitionRepo = transitionRepo;
|
||
_indexReconcileProducer = indexReconcileProducer;
|
||
_eventProcessor = eventProcessor;
|
||
_configService = configService;
|
||
_adminConfigService = adminConfigService;
|
||
_rateLimitService = rateLimitService;
|
||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||
_deviceAssociationValidator = deviceAssociationValidator;
|
||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||
_bootstrapSessionService = bootstrapSessionService;
|
||
_killSwitchEvaluator = killSwitchEvaluator;
|
||
_deletionGuard = deletionGuard;
|
||
}
|
||
|
||
[Function("IngestTelemetry")]
|
||
public async Task<IngestEventsOutput> Run(
|
||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/telemetry")] HttpRequestData req)
|
||
{
|
||
try
|
||
{
|
||
var tenantIdHeader = req.Headers.Contains("X-Tenant-Id")
|
||
? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault()
|
||
: null;
|
||
|
||
if (string.IsNullOrEmpty(tenantIdHeader))
|
||
{
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "X-Tenant-Id header is required"));
|
||
}
|
||
|
||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||
tenantIdHeader,
|
||
_configService,
|
||
_adminConfigService,
|
||
_rateLimitService,
|
||
_autopilotDeviceValidator,
|
||
_corporateIdentifierValidator,
|
||
_logger,
|
||
bootstrapSessionService: _bootstrapSessionService,
|
||
deviceAssociationValidator: _deviceAssociationValidator,
|
||
cloudPcDeviceValidator: _cloudPcDeviceValidator,
|
||
intuneDeviceBindingValidator: _intuneDeviceBindingValidator);
|
||
|
||
if (errorResponse != null) return AsOutput(errorResponse);
|
||
|
||
// Device + version kill-switches: short-circuit before parsing the body.
|
||
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
|
||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||
: null;
|
||
var agentVersionHeader = req.Headers.Contains("X-Agent-Version")
|
||
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault()
|
||
: null;
|
||
|
||
var killResponse = await CheckKillSwitchesAsync(
|
||
req, tenantIdHeader, serialNumberHeader, agentVersionHeader);
|
||
if (killResponse != null) return AsOutput(killResponse);
|
||
|
||
// Defense in depth: cap decompressed body size before buffering it all into
|
||
// memory + deserialising (same tenant-config knob the removed V1 NDJSON path
|
||
// used). Request body here is already gzip-decompressed by
|
||
// UseRequestDecompression — so the cap is on the actual JSON bytes we'll feed
|
||
// to the parser.
|
||
var tenantConfig = await _configService.GetConfigurationAsync(tenantIdHeader);
|
||
var maxPayloadBytes = (tenantConfig?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024;
|
||
|
||
bool exceeded;
|
||
List<TelemetryItemDto>? items;
|
||
try
|
||
{
|
||
(exceeded, items) = await ReadBodyWithSizeCapAsync(req.Body, maxPayloadBytes);
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
_logger.LogWarning(ex, "IngestTelemetry: malformed JSON body");
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "Malformed JSON body"));
|
||
}
|
||
|
||
if (exceeded)
|
||
{
|
||
_logger.LogWarning(
|
||
"IngestTelemetry: payload exceeds {Max}MB cap for tenant {Tenant}",
|
||
maxPayloadBytes / (1024 * 1024), tenantIdHeader);
|
||
return AsOutput(await WriteErrorAsync(
|
||
req, HttpStatusCode.RequestEntityTooLarge,
|
||
$"Payload exceeds {maxPayloadBytes / (1024 * 1024)} MB cap"));
|
||
}
|
||
|
||
if (items == null || items.Count == 0)
|
||
{
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "No telemetry items provided"));
|
||
}
|
||
|
||
// Extract tenant+session from the first item's PartitionKey for body-vs-header tenant check
|
||
// and for AdminAction / ServerAction lookups. All items in a batch must belong to one
|
||
// session — anything else is a client bug and gets the whole batch rejected (see
|
||
// the uniformity check below).
|
||
if (!TryParsePartitionKey(items[0].PartitionKey, out var bodyTenantId, out var sessionId))
|
||
{
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "Malformed PartitionKey"));
|
||
}
|
||
|
||
if (!string.Equals(bodyTenantId, tenantIdHeader, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogWarning(
|
||
"IngestTelemetry: TenantId mismatch — header={Header}, body={Body}",
|
||
tenantIdHeader, bodyTenantId);
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload"));
|
||
}
|
||
|
||
// Defense-in-depth: every item must carry the same PartitionKey as the first. Without
|
||
// this check a mixed-session batch would stamp every item with session A's identity
|
||
// inside PersistItemsAsync, silently relocating session B's signals/transitions into
|
||
// session A's primary rows.
|
||
if (FindMismatchingPartitionKey(items, out var mismatchIndex, out var mismatchedValue))
|
||
{
|
||
_logger.LogWarning(
|
||
"IngestTelemetry: PartitionKey mismatch at item[{Index}] — expected={Expected}, got={Got}",
|
||
mismatchIndex, items[0].PartitionKey, mismatchedValue);
|
||
return AsOutput(await WriteErrorAsync(
|
||
req, HttpStatusCode.BadRequest,
|
||
"All telemetry items in a batch must share the same PartitionKey"));
|
||
}
|
||
|
||
// Cascade-delete guard: refuse the batch with 410 Gone when a V2 cascade owns the
|
||
// Sessions row (states Preparing/Queued/Running/Poisoned). Without this check, the
|
||
// hot-path writers below (StoreEventsBatchAsync, signal/transition StoreBatchAsync,
|
||
// UpdateSessionImeAgentVersionAsync upsert, …) would land rows past the lock and
|
||
// leave orphan data the manifest cannot describe. One read per batch; absent
|
||
// Sessions row → silent pass (caller handles session-not-found in its own write).
|
||
Azure.Data.Tables.TableEntity? guardSessionRow;
|
||
try
|
||
{
|
||
guardSessionRow = await _deletionGuard.EnsureWritableAndGetRowAsync(bodyTenantId, sessionId, "V2.IngestTelemetry");
|
||
}
|
||
catch (SessionDeletionLockedException locked)
|
||
{
|
||
_logger.LogInformation(
|
||
"IngestTelemetry: refused batch — cascade in flight tenant={Tenant} session={Session} state={State} manifestId={ManifestId}",
|
||
bodyTenantId, sessionId, locked.CurrentState, locked.ManifestId);
|
||
return AsOutput(await WriteSessionLockedAsync(req, locked));
|
||
}
|
||
|
||
// Reuse the guard's full-row read: its Status feeds the stall-heal check inside
|
||
// EventIngestProcessor, saving one Sessions point-read per batch.
|
||
var preFetchedStatus = TryReadSessionStatus(guardSessionRow);
|
||
|
||
// P14: device-clock send time of this upload (one value per request). Separates
|
||
// spool delay from device-vs-server clock offset downstream; absent/garbage → null.
|
||
var sentAtHeader = req.Headers.Contains("X-Send-Time-Utc")
|
||
? req.Headers.GetValues("X-Send-Time-Utc").FirstOrDefault()
|
||
: null;
|
||
var sentAt = ParseSendTimeHeader(sentAtHeader);
|
||
|
||
// Partition + persist. Events are routed through EventIngestProcessor which runs the
|
||
// full pipeline (rule engine / app-install aggregation / SignalR / webhooks / ...);
|
||
// Signal + Transition go straight to their repositories.
|
||
var outcome = await PersistItemsAsync(items, bodyTenantId, sessionId, validation, preFetchedStatus, sentAt);
|
||
|
||
_logger.LogInformation(
|
||
"IngestTelemetry: tenant={Tenant} session={Session} events={E} signals={S} transitions={T} unknown={U}",
|
||
bodyTenantId, sessionId, outcome.EventCount, outcome.SignalCount, outcome.TransitionCount, outcome.UnknownCount);
|
||
|
||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||
{
|
||
Success = true,
|
||
EventsReceived = items.Count,
|
||
EventsProcessed = outcome.EventCount + outcome.SignalCount + outcome.TransitionCount,
|
||
Message = $"Stored {outcome.EventCount} events, {outcome.SignalCount} signals, {outcome.TransitionCount} transitions",
|
||
ProcessedAt = DateTime.UtcNow,
|
||
AdminAction = outcome.AdminAction,
|
||
Actions = outcome.PendingActions,
|
||
});
|
||
|
||
return new IngestEventsOutput
|
||
{
|
||
HttpResponse = response,
|
||
SignalRMessages = outcome.SignalRMessages,
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "IngestTelemetry: unhandled exception");
|
||
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.InternalServerError, "Internal server error"));
|
||
}
|
||
}
|
||
|
||
private static IngestEventsOutput AsOutput(HttpResponseData response)
|
||
=> new IngestEventsOutput
|
||
{
|
||
HttpResponse = response,
|
||
SignalRMessages = Array.Empty<SignalRMessageAction>(),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Runs device-serial and agent-version kill-switch checks (session-scoped blocks still
|
||
/// use the tenant/serial blanket check — tighter scoping lands once the body is parsed,
|
||
/// M5.b.2 pipeline share). Returns a 200 response with <c>DeviceBlocked=true</c> (and
|
||
/// optional <c>DeviceKillSignal=true</c>) if the caller should stop — or null if the
|
||
/// request may proceed. Evaluation + kill ops-event live in <see cref="KillSwitchEvaluator"/>,
|
||
/// shared with the agent-config channel.
|
||
/// </summary>
|
||
private async Task<HttpResponseData?> CheckKillSwitchesAsync(
|
||
HttpRequestData req, string tenantId, string? serialNumber, string? agentVersion)
|
||
{
|
||
var verdict = await _killSwitchEvaluator.EvaluateAsync(
|
||
tenantId, serialNumber, agentVersion, channel: "telemetry");
|
||
|
||
return verdict.IsBlocked
|
||
? await WriteDeviceBlockedAsync(req, verdict.IsKill, verdict.UnblockAt, verdict.Message)
|
||
: null;
|
||
}
|
||
|
||
private async Task<HttpResponseData> WriteDeviceBlockedAsync(
|
||
HttpRequestData req, bool isKill, DateTime? unblockAt, string message)
|
||
{
|
||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||
{
|
||
Success = false,
|
||
DeviceBlocked = true,
|
||
DeviceKillSignal = isKill,
|
||
UnblockAt = unblockAt,
|
||
Message = message,
|
||
ProcessedAt = DateTime.UtcNow,
|
||
});
|
||
return response;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Responds 410 Gone when the V2 cascade-delete guard refuses the batch. Per plan §5 PR3
|
||
/// wiring table, telemetry ingest of a locked session is a terminal condition for the
|
||
/// agent: the session is being torn down server-side and any further writes would create
|
||
/// orphan rows. 410 is the documented status; the body shape mirrors the device-blocked
|
||
/// response so existing agent code paths (which already short-circuit on Success=false)
|
||
/// drop the batch without retry.
|
||
/// </summary>
|
||
private static async Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||
{
|
||
var response = req.CreateResponse(HttpStatusCode.Gone);
|
||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||
{
|
||
Success = false,
|
||
Message = $"Session is being deleted by an administrator (state={locked.CurrentState}); further telemetry will be rejected.",
|
||
ProcessedAt = DateTime.UtcNow,
|
||
});
|
||
return response;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts <c>Status</c> from a Sessions row the deletion guard already loaded. Sessions
|
||
/// writes Status as a STRING (<c>status.ToString()</c> in UpdateSessionStatusAsync — never
|
||
/// an int), so this mirrors the canonical mapper's parse: <c>Enum.TryParse</c>,
|
||
/// case-insensitive. Returns null for missing/unparseable values (incl. a defensive int
|
||
/// fallback for any legacy numeric shape) — callers then fall back to their own read.
|
||
/// </summary>
|
||
internal static SessionStatus? TryReadSessionStatus(Azure.Data.Tables.TableEntity? sessionRow)
|
||
{
|
||
if (sessionRow == null || !sessionRow.TryGetValue("Status", out var statusValue))
|
||
return null;
|
||
|
||
return statusValue switch
|
||
{
|
||
string s when Enum.TryParse<SessionStatus>(s, ignoreCase: true, out var parsed) => parsed,
|
||
int i when Enum.IsDefined(typeof(SessionStatus), i) => (SessionStatus)i,
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Partitions the incoming batch by <see cref="TelemetryItemDto.Kind"/> and persists each
|
||
/// kind through its destination path. Events go through <see cref="EventIngestProcessor"/>
|
||
/// (the full event pipeline); Signals + Transitions land directly in their primary tables.
|
||
/// The returned <see cref="IngestOutcome"/> carries both the per-kind counts and the
|
||
/// control-signal / SignalR payload for the response.
|
||
/// </summary>
|
||
private async Task<IngestOutcome> PersistItemsAsync(
|
||
IReadOnlyList<TelemetryItemDto> items,
|
||
string tenantId,
|
||
string sessionId,
|
||
SecurityValidationResult validation,
|
||
SessionStatus? preFetchedStatus,
|
||
DateTime? sentAt)
|
||
{
|
||
var events = new List<EnrollmentEvent>();
|
||
var signals = new List<SignalRecord>();
|
||
var transitions = new List<DecisionTransitionRecord>();
|
||
var unknown = 0;
|
||
|
||
// Parse-null tracking: the per-line skip is deliberate (one poisonous item must not
|
||
// reject the batch), but the response stays Success=true and the agent deletes its
|
||
// spool — so a dropped item is gone forever. Without these counters an agent-side
|
||
// serialization regression would be fleet-wide silent data loss.
|
||
int eventParseFailed = 0, signalParseFailed = 0, transitionParseFailed = 0;
|
||
long? firstParseFailedItemId = null;
|
||
|
||
void TrackParseFailure(ref int counter, TelemetryItemDto item)
|
||
{
|
||
counter++;
|
||
firstParseFailedItemId ??= item.TelemetryItemId;
|
||
}
|
||
|
||
foreach (var item in items)
|
||
{
|
||
switch (item.Kind)
|
||
{
|
||
case "Event":
|
||
var evt = TelemetryPayloadParser.ParseEvent(item, tenantId, sessionId);
|
||
if (evt != null) events.Add(evt);
|
||
else TrackParseFailure(ref eventParseFailed, item);
|
||
break;
|
||
case "Signal":
|
||
var sig = TelemetryPayloadParser.ParseSignal(item, tenantId, sessionId);
|
||
if (sig != null) signals.Add(sig);
|
||
else TrackParseFailure(ref signalParseFailed, item);
|
||
break;
|
||
case "DecisionTransition":
|
||
var tr = TelemetryPayloadParser.ParseTransition(item, tenantId, sessionId);
|
||
if (tr != null) transitions.Add(tr);
|
||
else TrackParseFailure(ref transitionParseFailed, item);
|
||
break;
|
||
default:
|
||
unknown++;
|
||
_logger.LogWarning("IngestTelemetry: unknown Kind '{Kind}' (TelemetryItemId={Id})", item.Kind, item.TelemetryItemId);
|
||
break;
|
||
}
|
||
}
|
||
|
||
var parseFailed = eventParseFailed + signalParseFailed + transitionParseFailed;
|
||
if (parseFailed > 0)
|
||
{
|
||
_logger.LogWarning(
|
||
"IngestTelemetry: dropped {Total} unparseable item(s) of {BatchSize} for session {SessionId} " +
|
||
"(events={Events}, signals={Signals}, transitions={Transitions}, firstItemId={FirstItemId}) — " +
|
||
"items are lost (agent clears its spool on success)",
|
||
parseFailed, items.Count, sessionId,
|
||
eventParseFailed, signalParseFailed, transitionParseFailed, firstParseFailedItemId);
|
||
}
|
||
|
||
// Signals + Transitions write directly; they don't feed into the event pipeline.
|
||
var signalCount = await _signalRepo.StoreBatchAsync(signals);
|
||
var transitionCount = await _transitionRepo.StoreBatchAsync(transitions);
|
||
|
||
// M5.d.2: after the primary commit, fan out one envelope per row onto the
|
||
// telemetry-index-reconcile queue for the index-table writer (M5.d.3).
|
||
// The producer is feature-flag-gated (AdminConfiguration.EnableIndexDualWrite,
|
||
// default off) and never rethrows — see IIndexReconcileProducer contract.
|
||
if (signals.Count > 0 || transitions.Count > 0)
|
||
{
|
||
var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions);
|
||
await _indexReconcileProducer.EnqueueBatchAsync(envelopes);
|
||
}
|
||
|
||
int eventCount;
|
||
string? adminAction;
|
||
List<ServerAction>? pendingActions;
|
||
SignalRMessageAction[] signalRMessages;
|
||
|
||
if (events.Count > 0)
|
||
{
|
||
// Full event pipeline (rule engine, app-install aggregation, SignalR,
|
||
// vulnerability correlation, webhooks, SLA breach, AdminAction detection,
|
||
// ServerAction delivery).
|
||
var eventRequest = new IngestEventsRequest
|
||
{
|
||
SessionId = sessionId,
|
||
TenantId = tenantId,
|
||
Events = events,
|
||
SentAt = sentAt,
|
||
};
|
||
var processed = await _eventProcessor.ProcessEventsAsync(eventRequest, validation, preFetchedStatus);
|
||
|
||
eventCount = processed.EventsProcessed;
|
||
adminAction = processed.AdminAction;
|
||
pendingActions = processed.PendingActions;
|
||
signalRMessages = processed.SignalRMessages;
|
||
}
|
||
else
|
||
{
|
||
// Signal/Transition-only batch: no events means no event pipeline. We still honour
|
||
// the control-signal contract (AdminAction + pending ServerActions) because the
|
||
// agent reads them from every 2xx response regardless of which items it sent.
|
||
eventCount = 0;
|
||
var (aa, pa) = await ReadControlSignalsAsync(tenantId, sessionId);
|
||
adminAction = aa;
|
||
pendingActions = pa;
|
||
signalRMessages = Array.Empty<SignalRMessageAction>();
|
||
}
|
||
|
||
return new IngestOutcome
|
||
{
|
||
EventCount = eventCount,
|
||
SignalCount = signalCount,
|
||
TransitionCount = transitionCount,
|
||
UnknownCount = unknown,
|
||
AdminAction = adminAction,
|
||
PendingActions = pendingActions,
|
||
SignalRMessages = signalRMessages,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fetches <c>AdminAction</c> (session marked terminal out-of-band) and pending
|
||
/// <c>ServerAction</c>s for Signal/Transition-only batches (no <see cref="EventIngestProcessor"/>
|
||
/// invocation).
|
||
/// </summary>
|
||
private async Task<(string? adminAction, List<ServerAction>? pendingActions)>
|
||
ReadControlSignalsAsync(string tenantId, string sessionId)
|
||
{
|
||
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||
if (session == null) return (null, null);
|
||
|
||
// AdminAction carries the portal-button signal only. The old logic (Status ==
|
||
// Succeeded/Failed → AdminAction) also fired for agent-reported completion and made
|
||
// every follow-up signal/transition upload look like an out-of-band admin override.
|
||
string? adminAction = session.AdminMarkedAction;
|
||
|
||
List<ServerAction>? pendingActions = null;
|
||
if (!string.IsNullOrEmpty(session.PendingActionsJson))
|
||
{
|
||
var fetched = await _sessionRepo.FetchAndClearPendingActionsAsync(tenantId, sessionId);
|
||
if (fetched.Count > 0) pendingActions = fetched;
|
||
}
|
||
|
||
return (adminAction, pendingActions);
|
||
}
|
||
|
||
private sealed class IngestOutcome
|
||
{
|
||
public int EventCount;
|
||
public int SignalCount;
|
||
public int TransitionCount;
|
||
public int UnknownCount;
|
||
public string? AdminAction;
|
||
public List<ServerAction>? PendingActions;
|
||
public SignalRMessageAction[] SignalRMessages = Array.Empty<SignalRMessageAction>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Buffers the request body under a strict byte cap, then deserialises the telemetry batch
|
||
/// directly off the buffered bytes. Returns <c>(true, null)</c> as soon as the cap would be
|
||
/// exceeded — the stream isn't fully drained, which bounds memory use even for a malicious
|
||
/// sender (strict greater-than, so a payload equal to the cap is accepted).
|
||
/// <para>
|
||
/// Hot-path note: this deserialises through a <see cref="JsonTextReader"/> over the buffered
|
||
/// <see cref="MemoryStream"/> rather than materialising a full UTF-16 <c>string</c> + a
|
||
/// <c>ToArray()</c> copy first. That removes ~3× the body size in transient allocations per
|
||
/// request on the highest-volume backend endpoint — the reader decodes UTF-8 incrementally
|
||
/// in 8 KB chunks. The cap is still applied <i>before</i> parsing, so an over-cap body
|
||
/// never reaches the deserialiser.
|
||
/// </para>
|
||
/// Throws <see cref="JsonException"/> on malformed JSON (caller maps to 400). The
|
||
/// deserialiser uses the same default settings as <c>JsonConvert.DeserializeObject</c>, so
|
||
/// the parse semantics are unchanged — only the intermediate allocations are gone.
|
||
/// </summary>
|
||
internal static async Task<(bool exceeded, List<TelemetryItemDto>? items)> ReadBodyWithSizeCapAsync(
|
||
Stream source, int maxBytes)
|
||
{
|
||
using var buffered = new MemoryStream();
|
||
var buffer = new byte[8192];
|
||
int read;
|
||
long total = 0;
|
||
while ((read = await source.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||
{
|
||
total += read;
|
||
if (total > maxBytes) return (true, null);
|
||
await buffered.WriteAsync(buffer, 0, read);
|
||
}
|
||
|
||
buffered.Position = 0;
|
||
using var textReader = new StreamReader(buffered, System.Text.Encoding.UTF8);
|
||
using var jsonReader = new JsonTextReader(textReader);
|
||
var items = JsonSerializer.CreateDefault().Deserialize<List<TelemetryItemDto>>(jsonReader);
|
||
return (false, items);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Scans the batch for any item whose <see cref="TelemetryItemDto.PartitionKey"/> differs
|
||
/// from the first item's. Returns true + the offending index/value on mismatch; false if
|
||
/// every item shares the same PartitionKey (or the batch has < 2 items). Pure so the
|
||
/// guard can be unit-tested without a live HTTP trigger.
|
||
/// </summary>
|
||
internal static bool FindMismatchingPartitionKey(
|
||
IReadOnlyList<TelemetryItemDto> items,
|
||
out int mismatchIndex,
|
||
out string? mismatchedValue)
|
||
{
|
||
mismatchIndex = -1;
|
||
mismatchedValue = null;
|
||
if (items is null || items.Count < 2) return false;
|
||
|
||
var expected = items[0].PartitionKey;
|
||
for (var i = 1; i < items.Count; i++)
|
||
{
|
||
if (!string.Equals(items[i].PartitionKey, expected, StringComparison.Ordinal))
|
||
{
|
||
mismatchIndex = i;
|
||
mismatchedValue = items[i].PartitionKey;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses the <c>X-Send-Time-Utc</c> header (agent device-clock send time, ISO-8601
|
||
/// round-trip). Returns null for absent/unparseable values and for anything before
|
||
/// year 2000 — Table Storage rejects pre-1601 DateTimes outright, and a sub-2000
|
||
/// value can only be garbage, never a measurable clock offset. Deliberately NO upper
|
||
/// clamp: a future-dated send time IS the device-clock-error measurement this field
|
||
/// exists to capture.
|
||
/// </summary>
|
||
internal static DateTime? ParseSendTimeHeader(string? headerValue)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(headerValue)) return null;
|
||
|
||
if (!DateTimeOffset.TryParse(
|
||
headerValue,
|
||
System.Globalization.CultureInfo.InvariantCulture,
|
||
System.Globalization.DateTimeStyles.RoundtripKind,
|
||
out var parsed))
|
||
return null;
|
||
|
||
var utc = parsed.UtcDateTime;
|
||
return utc.Year >= 2000 ? utc : (DateTime?)null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// PartitionKey convention is <c>{tenantId}_{sessionId}</c> — both GUIDs with dashes, so
|
||
/// splitting on the single underscore between them is unambiguous. Returns false for any
|
||
/// shape that doesn't match (malformed, extra parts, empty halves).
|
||
/// </summary>
|
||
internal static bool TryParsePartitionKey(string partitionKey, out string tenantId, out string sessionId)
|
||
{
|
||
tenantId = string.Empty;
|
||
sessionId = string.Empty;
|
||
if (string.IsNullOrEmpty(partitionKey)) return false;
|
||
|
||
var parts = partitionKey.Split('_');
|
||
if (parts.Length != 2) return false;
|
||
if (string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) return false;
|
||
|
||
tenantId = parts[0];
|
||
sessionId = parts[1];
|
||
return true;
|
||
}
|
||
|
||
private static async Task<HttpResponseData> WriteErrorAsync(
|
||
HttpRequestData req, HttpStatusCode status, string message)
|
||
{
|
||
var response = req.CreateResponse(status);
|
||
await response.WriteAsJsonAsync(new IngestEventsResponse
|
||
{
|
||
Success = false,
|
||
EventsReceived = 0,
|
||
EventsProcessed = 0,
|
||
Message = message,
|
||
ProcessedAt = DateTime.UtcNow,
|
||
});
|
||
return response;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Multi-binding output shape of the agent ingest endpoint: the HTTP response plus the
|
||
/// SignalR messages for the live UI push.
|
||
/// </summary>
|
||
public class IngestEventsOutput
|
||
{
|
||
[HttpResult]
|
||
public HttpResponseData? HttpResponse { get; set; }
|
||
|
||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||
}
|
||
}
|