using System.IO;
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
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
{
///
/// V2 ingest endpoint. Consumes a heterogeneous batch of s
/// (Events + Signals + DecisionTransitions in a single JSON array, gzip-compressed on
/// the wire — the UseRequestDecompression middleware decompresses before we
/// parse). Plan §2.7a / §M5 / M4.6.ε.
///
/// Routing by :
///
/// - Event → (full pipeline parity with
/// /api/agent/ingest: rule engine, app-install aggregation, SignalR, vulnerability
/// correlation, webhooks, SLA breach, AdminAction detection, ServerAction delivery).
/// The processor is a deliberate copy of the legacy pipeline — see M5.b.2 rationale.
/// - Signal →
/// - DecisionTransition →
///
///
///
/// Response (M4.6.ε): the agent parses DeviceBlocked/UnblockAt/
/// DeviceKillSignal/AdminAction/Actions from the 2xx body and routes
/// kill-switches through its ServerActionDispatcher. Populated from the same
/// services the legacy /api/agent/ingest endpoint uses so behaviour is at parity.
///
///
public sealed class IngestTelemetryFunction
{
private readonly ILogger _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 RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly BlockedVersionService _blockedVersionService;
public IngestTelemetryFunction(
ILogger logger,
ISessionRepository sessionRepo,
ISignalRepository signalRepo,
IDecisionTransitionRepository transitionRepo,
IIndexReconcileProducer indexReconcileProducer,
EventIngestProcessor eventProcessor,
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
BootstrapSessionService bootstrapSessionService,
BlockedDeviceService blockedDeviceService,
BlockedVersionService blockedVersionService)
{
_logger = logger;
_sessionRepo = sessionRepo;
_signalRepo = signalRepo;
_transitionRepo = transitionRepo;
_indexReconcileProducer = indexReconcileProducer;
_eventProcessor = eventProcessor;
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_bootstrapSessionService = bootstrapSessionService;
_blockedDeviceService = blockedDeviceService;
_blockedVersionService = blockedVersionService;
}
[Function("IngestTelemetry")]
public async Task 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,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator);
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. Mirrors the legacy /api/agent/ingest NDJSON guard
// using the same tenant-config knob. 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;
var (exceeded, body) = await ReadBodyWithSizeCapAsync(req.Body, maxPayloadBytes);
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"));
}
List? items;
try
{
items = JsonConvert.DeserializeObject>(body);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "IngestTelemetry: malformed JSON body");
return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "Malformed JSON body"));
}
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"));
}
// 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);
_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(),
};
///
/// Runs device-serial and agent-version kill-switch checks. Returns a 200 response with
/// DeviceBlocked=true (and optional DeviceKillSignal=true) if the caller
/// should stop — or null if the request may proceed.
///
private async Task CheckKillSwitchesAsync(
HttpRequestData req, string tenantId, string? serialNumber, string? agentVersion)
{
if (!string.IsNullOrEmpty(serialNumber))
{
// Session-aware block: without body-parse we can't discriminate on SessionId,
// so we use the tenant/serial blanket check. Session-scoped blocks still require
// the agent to upload; the response just carries DeviceBlocked=true regardless of
// session — tighter scoping lands once the body is parsed (M5.b.2 pipeline share).
var (isBlocked, unblockAt, blockAction, _) =
await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumber);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"IngestTelemetry: {Action} device tenant={Tenant} serial={Serial} unblockAt={UnblockAt}",
isKill ? "KILL" : "Block", tenantId, serialNumber, unblockAt);
return await WriteDeviceBlockedAsync(req, isKill, unblockAt,
isKill ? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.");
}
}
if (!string.IsNullOrEmpty(agentVersion))
{
var (isVersionBlocked, versionAction, matchedPattern) =
await _blockedVersionService.IsVersionBlockedAsync(agentVersion);
if (isVersionBlocked)
{
var isKill = string.Equals(versionAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"IngestTelemetry: version {Action} tenant={Tenant} agentVersion={AgentVersion} pattern={Pattern}",
isKill ? "KILL" : "block", tenantId, agentVersion, matchedPattern);
return await WriteDeviceBlockedAsync(req, isKill, null,
isKill ? $"Agent version {agentVersion} has been issued a remote kill signal (pattern: {matchedPattern})."
: $"Agent version {agentVersion} is blocked by administrator (pattern: {matchedPattern}).");
}
}
return null;
}
private async Task 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;
}
///
/// Partitions the incoming batch by and persists each
/// kind through its destination path. Events go through
/// for full pipeline parity with legacy /api/agent/ingest; Signals + Transitions land
/// directly in their new primary tables. The returned carries
/// both the per-kind counts and the control-signal / SignalR payload for the response.
///
private async Task PersistItemsAsync(
IReadOnlyList items,
string tenantId,
string sessionId,
SecurityValidationResult validation)
{
var events = new List();
var signals = new List();
var transitions = new List();
var unknown = 0;
foreach (var item in items)
{
switch (item.Kind)
{
case "Event":
var evt = TelemetryPayloadParser.ParseEvent(item, tenantId, sessionId);
if (evt != null) events.Add(evt);
break;
case "Signal":
var sig = TelemetryPayloadParser.ParseSignal(item, tenantId, sessionId);
if (sig != null) signals.Add(sig);
break;
case "DecisionTransition":
var tr = TelemetryPayloadParser.ParseTransition(item, tenantId, sessionId);
if (tr != null) transitions.Add(tr);
break;
default:
unknown++;
_logger.LogWarning("IngestTelemetry: unknown Kind '{Kind}' (TelemetryItemId={Id})", item.Kind, item.TelemetryItemId);
break;
}
}
// 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? pendingActions;
SignalRMessageAction[] signalRMessages;
if (events.Count > 0)
{
// Full legacy-parity pipeline (rule engine, app-install aggregation, SignalR,
// vulnerability correlation, webhooks, SLA breach, AdminAction detection,
// ServerAction delivery). See EventIngestProcessor for the M5.b.2 copy-rationale.
var eventRequest = new IngestEventsRequest
{
SessionId = sessionId,
TenantId = tenantId,
Events = events,
};
var processed = await _eventProcessor.ProcessEventsAsync(eventRequest, validation);
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();
}
return new IngestOutcome
{
EventCount = eventCount,
SignalCount = signalCount,
TransitionCount = transitionCount,
UnknownCount = unknown,
AdminAction = adminAction,
PendingActions = pendingActions,
SignalRMessages = signalRMessages,
};
}
///
/// Fetches AdminAction (session marked terminal out-of-band) and pending
/// ServerActions for Signal/Transition-only batches (no
/// invocation). Mirror of the legacy inline logic.
///
private async Task<(string? adminAction, List? pendingActions)>
ReadControlSignalsAsync(string tenantId, string sessionId)
{
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
if (session == null) return (null, null);
string? adminAction = null;
if (session.Status == SessionStatus.Succeeded) adminAction = "Succeeded";
else if (session.Status == SessionStatus.Failed) adminAction = "Failed";
List? 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? PendingActions;
public SignalRMessageAction[] SignalRMessages = Array.Empty();
}
///
/// Reads a stream into a string with a strict byte cap. Returns (true, "") as
/// soon as the cap would be exceeded — the stream isn't fully drained, which bounds
/// memory use even for a malicious sender. Matches the legacy NDJSON parser's guard
/// semantics (strict greater-than, so a payload equal to the cap is accepted).
///
internal static async Task<(bool exceeded, string body)> 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, string.Empty);
await buffered.WriteAsync(buffer, 0, read);
}
return (false, System.Text.Encoding.UTF8.GetString(buffered.ToArray()));
}
///
/// Scans the batch for any item whose 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.
///
internal static bool FindMismatchingPartitionKey(
IReadOnlyList 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;
}
///
/// PartitionKey convention is {tenantId}_{sessionId} — 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).
///
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 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;
}
}
}