From f1e89146cbfd5a2442ed5558cb9aaa08fbb9085c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 21 Apr 2026 10:37:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20M5.c.1=20GET=20/api/sessions/{?= =?UTF-8?q?id}/signals=20=E2=80=94=20Inspector=20SignalLog=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three V2 Inspector read endpoints (Plan §M5). Returns the SignalLog for a session — ordered by SessionSignalOrdinal, bounded by a caller-supplied maxResults with a hard 5000 cap. - ISignalRepository.QueryBySessionAsync added alongside the existing StoreBatchAsync. Uses Azure Tables filter on PartitionKey = {tenant}_{session}; the D19(ordinal) RowKey padding means lex order already matches numeric order, so the explicit sort on return is a redundancy guard — cheap invariant check, not a real cost. - TableSignalRepository.FromEntity — reverse mapping (entity → SignalRecord) that mirrors ToEntity, including TableStorageChunking.ReassembleProperty for oversized PayloadJson. Round-trip tested end-to-end. - GetSessionSignalsFunction.Run — follows the same shape as GetSessionEventsFunction: RequestContext.TargetTenantId resolved by PolicyEnforcementMiddleware, Global-Admin cross-tenant fallback via SessionsIndex when the effective-tenant query comes up empty. - ParseMaxResults extracted as a pure helper taking the raw query string (not HttpRequestData) so it's unit-testable without mocking the abstract Worker HttpRequestData class. - Endpoint registered in EndpointAccessPolicyCatalog under MemberRead + TenantScoping.QueryParam (same policy as sessions/{id}/events). Without this the fail-closed catalog would reject the route with 403 — verified by the EndpointPolicyCatalogCompletenessTests. 9 new tests (614/614 backend green): ParseMaxResults defaults / garbage / in-range / cap / negative / null query string (6), plus FromEntity round-trip (simple payload + oversized-chunked payload). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GetSessionSignalsFunctionTests.cs | 61 ++++++++++ .../TableSignalRepositoryMappingTests.cs | 57 ++++++++++ .../TableStorage/TableSignalRepository.cs | 49 ++++++++ .../Sessions/GetSessionSignalsFunction.cs | 107 ++++++++++++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../DataAccess/ISignalRepository.cs | 8 ++ 6 files changed, 283 insertions(+) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs new file mode 100644 index 00000000..46ab9b94 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs @@ -0,0 +1,61 @@ +using AutopilotMonitor.Functions.Functions.Sessions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the pure helpers on . The HTTP-trigger +/// end needs a live runtime harness; this file covers the deterministic query-string +/// normalisation that decides how many rows we load. +/// +public class GetSessionSignalsFunctionTests +{ + [Fact] + public void ParseMaxResults_defaults_when_query_param_missing() + { + var result = GetSessionSignalsFunction.ParseMaxResults(""); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } + + [Fact] + public void ParseMaxResults_defaults_when_query_param_is_garbage() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana"); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } + + [Fact] + public void ParseMaxResults_honours_valid_value_inside_range() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250"); + + Assert.Equal(250, result); + } + + [Fact] + public void ParseMaxResults_caps_at_MaxResultsCap() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999"); + + Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result); + } + + [Theory] + [InlineData("?maxResults=0")] + [InlineData("?maxResults=-5")] + public void ParseMaxResults_floors_at_one_for_non_positive_values(string query) + { + var result = GetSessionSignalsFunction.ParseMaxResults(query); + + Assert.Equal(1, result); + } + + [Fact] + public void ParseMaxResults_handles_null_query_string() + { + var result = GetSessionSignalsFunction.ParseMaxResults(null!); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs index 8f9a10a6..3a180240 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs @@ -114,4 +114,61 @@ public class TableSignalRepositoryMappingTests Assert.Equal(string.Empty, entity.GetString("PayloadJson")); } + + // ============================================================ Reverse mapping (read path) + + [Fact] + public void FromEntity_round_trips_a_small_payload() + { + var original = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 42, + SessionTraceOrdinal = 117, + Kind = "EspExiting", + KindSchemaVersion = 2, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + SourceOrigin = "EspAndHelloTrackerAdapter", + PayloadJson = "{\"kind\":\"EspExiting\",\"evidence\":{\"kind\":\"Raw\"}}", + }; + + var entity = TableSignalRepository.ToEntity(original); + var restored = TableSignalRepository.FromEntity(entity); + + Assert.Equal(original.TenantId, restored.TenantId); + Assert.Equal(original.SessionId, restored.SessionId); + Assert.Equal(original.SessionSignalOrdinal, restored.SessionSignalOrdinal); + Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal); + Assert.Equal(original.Kind, restored.Kind); + Assert.Equal(original.KindSchemaVersion, restored.KindSchemaVersion); + Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(original.SourceOrigin, restored.SourceOrigin); + Assert.Equal(original.PayloadJson, restored.PayloadJson); + } + + [Fact] + public void FromEntity_reassembles_chunked_PayloadJson() + { + // Same-sized chunking behaviour we wrote — verifies the read path undoes it. + var oversized = new string('x', 75_000); + var original = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 1, + SessionTraceOrdinal = 1, + Kind = "EspPhaseChanged", + KindSchemaVersion = 1, + OccurredAtUtc = DateTime.UtcNow, + SourceOrigin = "t", + PayloadJson = oversized, + }; + + var entity = TableSignalRepository.ToEntity(original); + var restored = TableSignalRepository.FromEntity(entity); + + Assert.Equal(75_000, restored.PayloadJson.Length); + Assert.Equal(oversized, restored.PayloadJson); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs index 80675f3b..298d4e7f 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Azure; using Azure.Data.Tables; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; @@ -72,6 +73,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return committed; } + public async Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default) + { + SecurityValidator.EnsureValidGuid(tenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(sessionId, "SessionId"); + + var table = _storage.GetTableClient(Constants.TableNames.Signals); + var pk = BuildPartitionKey(tenantId, sessionId); + + var results = new List(capacity: Math.Min(maxResults, 128)); + var pages = table.QueryAsync( + filter: $"PartitionKey eq '{pk}'", + maxPerPage: Math.Min(maxResults, 1000), + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) break; + results.Add(FromEntity(entity)); + } + + // RowKey is D19(SessionSignalOrdinal) — Azure Tables returns PK-scoped rows in RowKey + // lex order which matches numeric ordinal order (that's the whole point of the padding). + // Explicit sort is redundant but cheap; keeps the invariant under a test. + results.Sort((a, b) => a.SessionSignalOrdinal.CompareTo(b.SessionSignalOrdinal)); + return results; + } + + /// + /// Projects an Azure back into a , + /// reassembling chunked PayloadJson if present. Internal for mapping tests. + /// + internal static SignalRecord FromEntity(TableEntity entity) + { + return new SignalRecord + { + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + SessionSignalOrdinal = entity.GetInt64("SessionSignalOrdinal") ?? 0, + SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0, + Kind = entity.GetString("Kind") ?? string.Empty, + KindSchemaVersion = entity.GetInt32("KindSchemaVersion") ?? 0, + OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default, + SourceOrigin = entity.GetString("SourceOrigin") ?? string.Empty, + PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty, + }; + } + /// /// Projects a onto its Azure shape. /// Keys: PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs new file mode 100644 index 00000000..68b45d88 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs @@ -0,0 +1,107 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/signals — returns the SignalLog for a V2 session + /// (Plan §M5 read endpoint, consumed by the Inspector's SignalStream panel and the MCP + /// get_decision_signals tool). + /// + /// Tenant scoping is resolved upstream by PolicyEnforcementMiddleware via + /// ; Global-Admin fallback mirrors the other + /// session-detail read endpoints (resolve actual tenant via SessionsIndex when the + /// effective tenant has no data). + /// + /// + public class GetSessionSignalsFunction + { + /// Hard upper bound on page size — protects against unbounded result sets. + internal const int MaxResultsCap = 5000; + /// Default result set size when the caller doesn't specify ?maxResults=. + internal const int DefaultMaxResults = 1000; + + private readonly ILogger _logger; + private readonly ISignalRepository _signalRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionSignalsFunction( + ILogger logger, + ISignalRepository signalRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _signalRepo = signalRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionSignals")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/signals")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var maxResults = ParseMaxResults(req.Url.Query); + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionSignals: maxResults={Max}", sessionPrefix, maxResults); + + try + { + var requestCtx = req.GetRequestContext(); + + var signals = await _signalRepo.QueryBySessionAsync( + requestCtx.TargetTenantId, sessionId, maxResults); + + // Global Admin cross-tenant fallback — mirror GetSessionEventsFunction pattern. + if (signals.Count == 0 && requestCtx.IsGlobalAdmin) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults); + } + } + + return await req.OkAsync(new + { + success = true, + sessionId, + count = signals.Count, + truncated = signals.Count >= maxResults, + signals, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Get signals for session '{sessionId}'"); + } + } + + /// + /// Reads ?maxResults= from the query string, clamping to [1, ]. + /// Returns when the parameter is missing or malformed. + /// Takes the raw query string so the helper is unit-testable without mocking + /// HttpRequestData (abstract, painful to fake). + /// + internal static int ParseMaxResults(string queryString) + { + var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty); + var raw = query["maxResults"]; + if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed)) return DefaultMaxResults; + if (parsed < 1) return 1; + if (parsed > MaxResultsCap) return MaxResultsCap; + return parsed; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 3e8e8fd4..a426b06e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -139,6 +139,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "sessions", EndpointPolicy.MemberRead), new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "sessions/{sessionId}/signals", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "metrics/app", EndpointPolicy.MemberRead), diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs index 038b195d..86107880 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs @@ -21,5 +21,13 @@ namespace AutopilotMonitor.Shared.DataAccess /// Upserts a batch of signal records. Returns the number of rows committed. /// Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default); + + /// + /// Reads up to signal records for a single session, + /// ordered by ascending. Used by the + /// Inspector read endpoint (GET /api/sessions/{id}/signals, Plan §M5). + /// + Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default); } }