mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
5163d7d6a0
73 sites in 68 files built the query collection with HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET Core request type does not override it, so the change is behaviour-neutral. - QueryParamGuardTests: ParseQueryString in the Functions project is limited to a named baseline of three sites that parse something other than the request query. - A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded values, caching) so a worker update that changes the implementation fails a test. - Test mocks that set Url answer Query through CallBase; a loose mock returns null. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
216 lines
11 KiB
C#
216 lines
11 KiB
C#
using System.Net;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Pagination;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Pagination;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Sessions
|
|
{
|
|
public class GetSessionEventsFunction
|
|
{
|
|
private readonly ILogger<GetSessionEventsFunction> _logger;
|
|
private readonly ISessionRepository _sessionRepo;
|
|
|
|
public GetSessionEventsFunction(
|
|
ILogger<GetSessionEventsFunction> logger,
|
|
ISessionRepository sessionRepo)
|
|
{
|
|
_logger = logger;
|
|
_sessionRepo = sessionRepo;
|
|
}
|
|
|
|
[Function("GetSessionEvents")]
|
|
public async Task<HttpResponseData> Run(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/events")] HttpRequestData req,
|
|
string sessionId)
|
|
{
|
|
if (string.IsNullOrEmpty(sessionId))
|
|
{
|
|
return await req.BadRequestAsync("SessionId is required");
|
|
}
|
|
|
|
var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
|
|
var query = req.Query;
|
|
var pagination = SessionEventsPagination.ParseQuery(query);
|
|
if (pagination.Error != null)
|
|
{
|
|
return await req.BadRequestAsync(pagination.Error);
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"{Prefix} GetSessionEvents: Fetching events (pageSize={PageSize}, hasContinuation={HasContinuation})",
|
|
sessionPrefix, pagination.PageSize?.ToString() ?? "all", pagination.Continuation != null);
|
|
|
|
try
|
|
{
|
|
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
|
// Cross-tenant access check handled by middleware (TargetTenantId)
|
|
var requestCtx = req.GetRequestContext();
|
|
var tenantIdQueryParam = query["tenantId"];
|
|
|
|
// Optional server-side filters (post-fetch). Previously these lived in the MCP
|
|
// tool layer, which made count and nextLink semantics inconsistent (filter →
|
|
// count: 0 + nextLink "more ahead" was confusing). Centralizing here keeps
|
|
// count and nextLink coherent for every consumer.
|
|
var filterEventType = query["eventType"];
|
|
var filterSeverity = query["severity"];
|
|
var filterSource = query["source"];
|
|
var filterFields = query["fields"];
|
|
var hasFilters = !string.IsNullOrEmpty(filterEventType)
|
|
|| !string.IsNullOrEmpty(filterSeverity)
|
|
|| !string.IsNullOrEmpty(filterSource);
|
|
|
|
static IEnumerable<EnrollmentEvent> ApplyFilters(
|
|
IEnumerable<EnrollmentEvent> source,
|
|
string? eventType, string? severity, string? sourceFilter)
|
|
{
|
|
EventSeverity? wantSeverity = null;
|
|
if (!string.IsNullOrEmpty(severity)
|
|
&& Enum.TryParse<EventSeverity>(severity, ignoreCase: true, out var parsed))
|
|
{
|
|
wantSeverity = parsed;
|
|
}
|
|
|
|
foreach (var e in source)
|
|
{
|
|
if (!string.IsNullOrEmpty(eventType) &&
|
|
!string.Equals(e.EventType, eventType, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (wantSeverity.HasValue && e.Severity != wantSeverity.Value)
|
|
continue;
|
|
if (!string.IsNullOrEmpty(sourceFilter) &&
|
|
(e.Source == null ||
|
|
e.Source.IndexOf(sourceFilter, StringComparison.OrdinalIgnoreCase) < 0))
|
|
continue;
|
|
yield return e;
|
|
}
|
|
}
|
|
|
|
if (pagination.PageSize == null)
|
|
{
|
|
// Legacy unpaginated path — full list, no nextLink. Global-scope callers
|
|
// resolve the session's owning tenant UPFRONT (point-read) — the former
|
|
// "events.Count == 0 → cross-partition scan" retry conflated "session lives
|
|
// elsewhere" with "session genuinely has no events" and paid a full
|
|
// SessionsIndex scan for every empty result.
|
|
var unpaginatedTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
|
var events = await _sessionRepo.GetSessionEventsAsync(unpaginatedTenantId, sessionId);
|
|
|
|
var filtered = hasFilters
|
|
? ApplyFilters(events, filterEventType, filterSeverity, filterSource).ToList()
|
|
: events;
|
|
|
|
// Skip error-code enrichment when the projection drops Data (enrichment only
|
|
// writes into Data) — pure work avoidance for lean fields= queries.
|
|
if (EventFieldProjection.WantsData(filterFields))
|
|
ErrorCodeEnricher.EnrichEvents(filtered);
|
|
|
|
return await req.OkAsync(new GetSessionEventsResponse
|
|
{
|
|
Success = true,
|
|
SessionId = sessionId,
|
|
Count = filtered.Count,
|
|
Events = EventFieldProjection.Project(filtered, filterFields),
|
|
});
|
|
}
|
|
|
|
// Paginated path. Continuation token binds (tenantId, sessionId), so the
|
|
// tenantId on every page must match the tenant the token was issued for.
|
|
//
|
|
// For a global-scope caller (GA or read-only GlobalReader) cross-tenant, the nextLink we
|
|
// emit echoes that tenant back as ?tenantId=, so follow-up pages can re-bind to it. On
|
|
// page 1, if no tenantId was passed yet, we resolve it from the session lookup.
|
|
//
|
|
// For non-global callers, ?tenantId= in the URL is ignored — middleware-bound JWT
|
|
// tenant is authoritative; we must never let a query param override it.
|
|
var effectiveTenantId = requestCtx.TargetTenantId;
|
|
|
|
if (requestCtx.HasGlobalScope)
|
|
{
|
|
if (!string.IsNullOrEmpty(tenantIdQueryParam))
|
|
{
|
|
// Caller passed an explicit tenantId — including via the
|
|
// nextLink we emitted on a prior page. Cheap-path: trust it,
|
|
// skip the storage probe.
|
|
effectiveTenantId = tenantIdQueryParam;
|
|
}
|
|
else
|
|
{
|
|
// No tenantId in URL. Resolve it from the session lookup
|
|
// *on every page*, not just page 1, so external callers
|
|
// that strip nextLink down to the bare continuation token
|
|
// (older MCP clients, deployed agents) still validate.
|
|
// The lookup is one point-read on SessionTenantLookup —
|
|
// negligible compared to the events page fetch itself.
|
|
effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
|
}
|
|
}
|
|
|
|
string? azureToken = null;
|
|
if (pagination.Continuation != null)
|
|
{
|
|
if (!SessionEventsPagination.TryAcceptContinuation(
|
|
pagination.Continuation, effectiveTenantId, sessionId, out azureToken, out var reason))
|
|
{
|
|
_logger.LogWarning(
|
|
"{Prefix} GetSessionEvents: continuation rejected ({Reason})",
|
|
sessionPrefix, reason);
|
|
return await req.BadRequestAsync($"Invalid continuation token ({reason}). Restart pagination from the first page.");
|
|
}
|
|
}
|
|
|
|
var page = await _sessionRepo.GetSessionEventsPageAsync(
|
|
effectiveTenantId, sessionId, pagination.PageSize.Value, azureToken);
|
|
|
|
var pageItems = hasFilters
|
|
? ApplyFilters(page.Items, filterEventType, filterSeverity, filterSource).ToList()
|
|
: page.Items;
|
|
|
|
if (EventFieldProjection.WantsData(filterFields))
|
|
ErrorCodeEnricher.EnrichEvents(pageItems);
|
|
|
|
string? nextLink = null;
|
|
if (!string.IsNullOrEmpty(page.NextRawToken))
|
|
{
|
|
// Filters are echoed onto the nextLink so the client doesn't have to
|
|
// re-supply them when continuing pagination. Both BuildNextLink and the
|
|
// client (MCP, UI) round-trip these via the continuation URL verbatim.
|
|
var extras = new List<KeyValuePair<string, string?>>(4);
|
|
if (!string.IsNullOrEmpty(filterEventType))
|
|
extras.Add(new KeyValuePair<string, string?>("eventType", filterEventType));
|
|
if (!string.IsNullOrEmpty(filterSeverity))
|
|
extras.Add(new KeyValuePair<string, string?>("severity", filterSeverity));
|
|
if (!string.IsNullOrEmpty(filterSource))
|
|
extras.Add(new KeyValuePair<string, string?>("source", filterSource));
|
|
if (!string.IsNullOrEmpty(filterFields))
|
|
extras.Add(new KeyValuePair<string, string?>("fields", filterFields));
|
|
|
|
var fp = SessionEventsPagination.Fingerprint(effectiveTenantId, sessionId);
|
|
var wireToken = ContinuationToken.Encode(page.NextRawToken!, effectiveTenantId, fp);
|
|
nextLink = SessionEventsPagination.BuildNextLink(
|
|
sessionId, pagination.PageSize.Value, wireToken, effectiveTenantId,
|
|
extras: extras.Count > 0 ? extras : null);
|
|
}
|
|
|
|
return await req.OkAsync(new GetSessionEventsResponse
|
|
{
|
|
Success = true,
|
|
SessionId = sessionId,
|
|
Count = pageItems.Count,
|
|
Events = EventFieldProjection.Project(pageItems, filterFields),
|
|
NextLink = nextLink,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, $"Get events for session '{sessionId}'");
|
|
}
|
|
}
|
|
}
|
|
}
|