From 841f1ae04c2fa234c76ccb847a6d581ff8a8497c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 6 Jun 2026 11:36:02 +0200 Subject: [PATCH] feat(mcp): add action/performedBy/entityType/entityId filters to get_audit_logs The get_audit_logs MCP tool could only narrow by tenant + date window, even though the audit entity already stores Action/PerformedBy/EntityType/EntityId. Add exact-match server-side filtering across all three layers so a filtered query never falls back to in-memory scanning. - Shared: AuditLogQueryFilters carrier + extended IMaintenanceRepository - Storage: shared AppendAuditFieldFilters (OData-escaped eq clauses) in both the tenant and global fan-out filter builders; threaded through page + legacy methods - Functions: new shared AuditLogFilterRequest parser folds filters into the pagination fingerprint AND echoes them on nextLink (cross-filter token replay rejected, follow-up pages round-trip), identical for tenant + global - MCP: four optional params on get_audit_logs + pass-through - Tests: AuditLogFieldFilterTests (eq clauses, escaping, coexistence with performer suppression + deletion exclusion, extras ordering) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AuditLogFieldFilterTests.cs | 118 ++++++++++++++++++ .../OffboardingSessionEnumeratorTests.cs | 8 +- .../TableMaintenanceRepository.cs | 18 +-- .../Functions/Admin/AuditLogFilterRequest.cs | 49 ++++++++ .../Functions/Admin/GetAuditLogsFunction.cs | 30 +++-- .../Admin/GetGlobalAuditLogsFunction.cs | 20 ++- .../TableStorageService.Maintenance.cs | 51 ++++++-- .../autopilot-monitor-mcp/src/tools/admin.ts | 17 ++- .../DataAccess/IMaintenanceRepository.cs | 39 +++++- .../docs/sections/SectionMcpIntegration.tsx | 2 +- 10 files changed, 303 insertions(+), 49 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AuditLogFieldFilterTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Admin/AuditLogFilterRequest.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogFieldFilterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogFieldFilterTests.cs new file mode 100644 index 00000000..0ce96bba --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogFieldFilterTests.cs @@ -0,0 +1,118 @@ +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using System.Collections.Specialized; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Exact-match field filters (action / performedBy / entityType / entityId) for the +/// audit-log views. They are folded into the server-side OData query so a filtered +/// query never falls back to in-memory scanning, and into the pagination fingerprint +/// (via the request extras) so a token minted for one filter set can't be replayed +/// against another. +/// +public class AuditLogFieldFilterTests +{ + private static readonly string Tenant = "11111111-1111-1111-1111-111111111111"; + + [Fact] + public void Tenant_filter_omits_field_clauses_when_no_filters() + { + var f = TableStorageService.BuildAuditLogFilter(Tenant, null, null, excludeDeletions: false, filters: null); + Assert.DoesNotContain("Action eq", f); + Assert.DoesNotContain("PerformedBy eq", f); + Assert.DoesNotContain("EntityType eq", f); + Assert.DoesNotContain("EntityId eq", f); + } + + [Fact] + public void Tenant_filter_adds_eq_clause_per_supplied_field() + { + var filters = new AuditLogQueryFilters + { + Action = "config_updated", + PerformedBy = "alice@contoso.com", + EntityType = "TenantConfiguration", + EntityId = "tenant-42", + }; + + var f = TableStorageService.BuildAuditLogFilter(Tenant, null, null, excludeDeletions: false, filters); + + Assert.Contains("Action eq 'config_updated'", f); + Assert.Contains("PerformedBy eq 'alice@contoso.com'", f); + Assert.Contains("EntityType eq 'TenantConfiguration'", f); + Assert.Contains("EntityId eq 'tenant-42'", f); + } + + [Fact] + public void Global_fanout_filter_adds_eq_clause_per_supplied_field() + { + var filters = new AuditLogQueryFilters { Action = "device_blocked", EntityType = "Device" }; + + var f = TableStorageService.BuildAuditLogFilterWithRowKeyBound( + Tenant, null, null, lastRowKey: null, excludeDeletions: false, filters); + + Assert.Contains("Action eq 'device_blocked'", f); + Assert.Contains("EntityType eq 'Device'", f); + Assert.DoesNotContain("PerformedBy eq", f); // not supplied + } + + [Fact] + public void Field_filter_coexists_with_performer_suppression_and_deletion_exclusion() + { + var filters = new AuditLogQueryFilters { Action = "deletion_started" }; + var f = TableStorageService.BuildAuditLogFilter(Tenant, null, null, excludeDeletions: true, filters); + + // The new positive filter and the pre-existing negative noise filters all AND together. + Assert.Contains("Action eq 'deletion_started'", f); + Assert.Contains("PerformedBy ne 'System.Maintenance'", f); + Assert.Contains("Action ne 'deletion_started'", f); + } + + [Fact] + public void Field_values_are_odata_escaped() + { + // A stray single quote must be doubled, not break out of the literal. + var filters = new AuditLogQueryFilters { PerformedBy = "o'brien@contoso.com" }; + var f = TableStorageService.BuildAuditLogFilter(Tenant, null, null, excludeDeletions: false, filters); + Assert.Contains("PerformedBy eq 'o''brien@contoso.com'", f); + } + + [Fact] + public void Request_parse_maps_query_params_and_empty_is_empty() + { + var query = new NameValueCollection + { + { "action", "config_updated" }, + { "performedBy", "alice@contoso.com" }, + { "entityType", "TenantConfiguration" }, + { "entityId", "tenant-42" }, + }; + + var parsed = AuditLogFilterRequest.Parse(query); + Assert.Equal("config_updated", parsed.Action); + Assert.Equal("alice@contoso.com", parsed.PerformedBy); + Assert.Equal("TenantConfiguration", parsed.EntityType); + Assert.Equal("tenant-42", parsed.EntityId); + Assert.False(parsed.IsEmpty); + + var empty = AuditLogFilterRequest.Parse(new NameValueCollection()); + Assert.True(empty.IsEmpty); + } + + [Fact] + public void Request_extras_are_ordered_and_skip_empty_fields() + { + // Order must be stable: the fingerprint computed at mint time has to match + // the one recomputed from the echoed nextLink params on the follow-up call. + var filters = new AuditLogQueryFilters { EntityType = "Device", Action = "device_blocked" }; + var extras = AuditLogFilterRequest.ToExtras(filters); + + Assert.Equal(2, extras.Count); + Assert.Equal("action", extras[0].Key); + Assert.Equal("device_blocked", extras[0].Value); + Assert.Equal("entityType", extras[1].Key); + Assert.Equal("Device", extras[1].Value); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs index b8ee1a2e..09cd0ae0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs @@ -104,10 +104,10 @@ public class OffboardingSessionEnumeratorTests // ── Unused for these tests ───────────────────────────────────────────── public Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null) => throw new NotImplementedException(); - public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException(); - public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException(); - public Task> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException(); - public Task> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException(); + public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null, AuditLogQueryFilters? filters = null) => throw new NotImplementedException(); + public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, AuditLogQueryFilters? filters = null) => throw new NotImplementedException(); + public Task> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException(); + public Task> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException(); public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => throw new NotImplementedException(); public Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException(); public Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs index 10e0aef0..2a1d781d 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -30,21 +30,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage string entityId, string performedBy, Dictionary? details = null) => _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details); - public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) - => _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo); + public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null) + => _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo, filters); - public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) - => _storage.GetAllAuditLogsAsync(dateFrom, dateTo); + public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null) + => _storage.GetAllAuditLogsAsync(dateFrom, dateTo, filters); public Task> GetAuditLogsPageAsync( string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false) - => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation, excludeDeletions); + bool excludeDeletions = false, AuditLogQueryFilters? filters = null) + => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation, excludeDeletions, filters); public Task> GetAllAuditLogsPageAsync( DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false) - => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions); + bool excludeDeletions = false, AuditLogQueryFilters? filters = null) + => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions, filters); public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AuditLogFilterRequest.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AuditLogFilterRequest.cs new file mode 100644 index 00000000..ea67c5c1 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AuditLogFilterRequest.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Shared.DataAccess; + +namespace AutopilotMonitor.Functions.Functions.Admin +{ + /// + /// Parses the optional audit-log field filters (action / performedBy + /// / entityType / entityId) from the request query and projects them + /// into both the storage and the pagination + /// extras (key/value pairs folded into the continuation fingerprint and echoed on + /// nextLink). Shared by the tenant and global audit endpoints so both apply + /// the exact same filter surface — a drift would let one view honour a filter the + /// other ignores, or mint pagination tokens the other rejects. + /// + internal static class AuditLogFilterRequest + { + public static AuditLogQueryFilters Parse(NameValueCollection query) => new AuditLogQueryFilters + { + Action = NullIfEmpty(query["action"]), + PerformedBy = NullIfEmpty(query["performedBy"]), + EntityType = NullIfEmpty(query["entityType"]), + EntityId = NullIfEmpty(query["entityId"]), + }; + + /// + /// Returns the non-empty filter values as ordered query-param pairs. The order + /// is fixed so the fingerprint computed at mint time matches the one recomputed + /// when the echoed nextLink params are re-parsed on the follow-up request. + /// + public static List> ToExtras(AuditLogQueryFilters filters) + { + var extras = new List>(); + Add(extras, "action", filters.Action); + Add(extras, "performedBy", filters.PerformedBy); + Add(extras, "entityType", filters.EntityType); + Add(extras, "entityId", filters.EntityId); + return extras; + } + + private static void Add(List> extras, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + extras.Add(new KeyValuePair(key, value)); + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index 33c5a5da..0dfc1bd3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -49,15 +49,22 @@ namespace AutopilotMonitor.Functions.Functions.Admin var excludeDeletions = string.Equals(query["excludeDeletions"], "true", StringComparison.OrdinalIgnoreCase); var fingerprintScope = excludeDeletions ? "audit:tenant:nodel" : "audit:tenant"; + // Optional exact-match field filters (action / performedBy / entityType / + // entityId). Folded into the fingerprint (via filterExtras) so a token + // minted for one filter set can't be replayed against another, and echoed + // on nextLink so the follow-up request re-parses an identical set. + var filters = AuditLogFilterRequest.Parse(query); + var filterExtras = AuditLogFilterRequest.ToExtras(filters); + _logger.LogInformation( - "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}, excludeDeletions={ExcludeDeletions}) for user {User}", + "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}, excludeDeletions={ExcludeDeletions}, filters={FilterCount}) for user {User}", tenantId, parsed.DateFrom, parsed.DateTo, - parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, excludeDeletions, + parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, excludeDeletions, filterExtras.Count, userIdentifier); if (parsed.PageSize == null) { - var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, parsed.DateFrom, parsed.DateTo); + var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, parsed.DateFrom, parsed.DateTo, filters); return await req.OkAsync(new { success = true, count = logs.Count, logs }); } @@ -70,7 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin callerTenantId: tenantId, dateFrom: parsed.DateFrom, dateTo: parsed.DateTo, - extras: null, + extras: filterExtras, out azureToken, out var rejectReason)) { @@ -86,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin } var page = await _maintenanceRepo.GetAuditLogsPageAsync( - tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions); + tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions, filters); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) @@ -95,17 +102,22 @@ namespace AutopilotMonitor.Functions.Functions.Admin scope: fingerprintScope, callerTenantId: tenantId, dateFrom: parsed.DateFrom, - dateTo: parsed.DateTo); + dateTo: parsed.DateTo, + extras: filterExtras); var wireToken = ContinuationToken.Encode(page.NextRawToken!, tenantId, fp); + // nextLink must carry both the filter params (so the follow-up + // recomputes an identical fingerprint) and excludeDeletions (which + // is bound via the scope string, re-derived from the echoed param). + var nextLinkExtras = new List>(filterExtras); + if (excludeDeletions) + nextLinkExtras.Add(new KeyValuePair("excludeDeletions", "true")); nextLink = DateWindowPagination.BuildNextLink( basePath: "/api/audit/logs", pageSize: parsed.PageSize.Value, wireContinuation: wireToken, dateFrom: parsed.DateFrom, dateTo: parsed.DateTo, - extras: excludeDeletions - ? new[] { new KeyValuePair("excludeDeletions", "true") } - : null); + extras: nextLinkExtras); } return await req.OkAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index 0d95442c..b0bf32b8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -57,22 +57,30 @@ namespace AutopilotMonitor.Functions.Functions.Admin ? "audit:global" : $"audit:global:tenant:{filterTenantId}"; var fingerprintScope = excludeDeletions ? $"{baseScope}:nodel" : baseScope; + // Optional exact-match field filters (action / performedBy / entityType / + // entityId). Appended to nextLinkExtras so they are bound into the + // fingerprint (cross-filter token replay is rejected) AND echoed on + // nextLink for self-contained follow-up requests — same mechanism the + // tenantId/excludeDeletions discriminators already use here. + var filters = AuditLogFilterRequest.Parse(query); + var filterExtras = AuditLogFilterRequest.ToExtras(filters); var nextLinkExtras = new List>(); if (!string.IsNullOrEmpty(filterTenantId)) nextLinkExtras.Add(new KeyValuePair("tenantId", filterTenantId)); if (excludeDeletions) nextLinkExtras.Add(new KeyValuePair("excludeDeletions", "true")); + nextLinkExtras.AddRange(filterExtras); _logger.LogInformation( - "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}, excludeDeletions={ExcludeDeletions}) for {User}", + "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}, excludeDeletions={ExcludeDeletions}, filters={FilterCount}) for {User}", parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", - filterTenantId ?? "(none)", excludeDeletions, userEmail); + filterTenantId ?? "(none)", excludeDeletions, filterExtras.Count, userEmail); if (parsed.PageSize == null) { var logs = string.IsNullOrEmpty(filterTenantId) - ? await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo) - : await _maintenanceRepo.GetAuditLogsAsync(filterTenantId, parsed.DateFrom, parsed.DateTo); + ? await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo, filters) + : await _maintenanceRepo.GetAuditLogsAsync(filterTenantId, parsed.DateFrom, parsed.DateTo, filters); return await req.OkAsync(new { success = true, count = logs.Count, logs }); } @@ -102,9 +110,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin var page = string.IsNullOrEmpty(filterTenantId) ? await _maintenanceRepo.GetAllAuditLogsPageAsync( - parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions) + parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions, filters) : await _maintenanceRepo.GetAuditLogsPageAsync( - filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions); + filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions, filters); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 20337c03..dfdbf80f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -74,14 +74,15 @@ namespace AutopilotMonitor.Functions.Services /// Gets audit log entries for a tenant within an optional UTC date window. /// No row cap — returns the full filtered set, sorted newest-first. /// - public async Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) + public async Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var filter = BuildAuditLogFilter(tenantId, dateFrom, dateTo); + var filter = BuildAuditLogFilter(tenantId, dateFrom, dateTo, excludeDeletions: false, filters); var query = tableClient.QueryAsync(filter: filter); var logs = new List(); @@ -102,12 +103,13 @@ namespace AutopilotMonitor.Functions.Services /// /// Gets audit log entries across all tenants (Global Admin Mode), optional UTC window. /// - public async Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) + public async Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null) { try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var filter = BuildAuditLogFilter(tenantId: null, dateFrom, dateTo); + var filter = BuildAuditLogFilter(tenantId: null, dateFrom, dateTo, excludeDeletions: false, filters); var query = string.IsNullOrEmpty(filter) ? tableClient.QueryAsync() : tableClient.QueryAsync(filter: filter); @@ -129,21 +131,21 @@ namespace AutopilotMonitor.Functions.Services public Task> GetAuditLogsPageAsync( string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false) + bool excludeDeletions = false, AuditLogQueryFilters? filters = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); return FetchAuditLogPageInternalAsync( - BuildAuditLogFilter(tenantId, dateFrom, dateTo, excludeDeletions), pageSize, continuation); + BuildAuditLogFilter(tenantId, dateFrom, dateTo, excludeDeletions, filters), pageSize, continuation); } public Task> GetAllAuditLogsPageAsync( DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false) + bool excludeDeletions = false, AuditLogQueryFilters? filters = null) { // Cross-tenant: per-tenant fan-out + merge by RowKey. Without this // fan-out Azure pages by (PK asc, RK asc) cross-partition, surfacing // tenants alphabetically rather than newest-first globally. - return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions); + return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions, filters); } // Upper bound on Azure round-trips per page when back-filling past @@ -206,7 +208,8 @@ namespace AutopilotMonitor.Functions.Services } private async Task> FetchAllAuditLogsPageAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions) + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions, + AuditLogQueryFilters? filters = null) { if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); try @@ -252,7 +255,7 @@ namespace AutopilotMonitor.Functions.Services var fetchTasks = activeTenantIds.Select(async tid => { continuations.TryGetValue(tid, out var prior); - var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey, excludeDeletions); + var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey, excludeDeletions, filters); var fetched = new List<(string RowKey, AuditLogEntry Item)>(); await foreach (var e in tableClient.QueryAsync(filter: tenantFilter, maxPerPage: pageSize)) { @@ -306,8 +309,29 @@ namespace AutopilotMonitor.Functions.Services internal static string DeletionExclusionClause() => $"Action ne '{AuditLogDeletionStartedAction}' and Action ne '{AuditLogDeletionCompletedAction}'"; + // Escapes a string literal for an OData filter (single quotes are doubled). + private static string EscapeOData(string value) => value.Replace("'", "''"); + + // Appends optional exact-match field-filter clauses (action / performedBy / + // entityType / entityId). Shared by the tenant and global filter builders so + // both audit views filter identically — a drift would silently scope the two + // paths differently. Each non-empty value becomes a server-side `eq` clause. + private static void AppendAuditFieldFilters(List clauses, AuditLogQueryFilters? filters) + { + if (filters == null) return; + if (!string.IsNullOrEmpty(filters.Action)) + clauses.Add($"Action eq '{EscapeOData(filters.Action!)}'"); + if (!string.IsNullOrEmpty(filters.PerformedBy)) + clauses.Add($"PerformedBy eq '{EscapeOData(filters.PerformedBy!)}'"); + if (!string.IsNullOrEmpty(filters.EntityType)) + clauses.Add($"EntityType eq '{EscapeOData(filters.EntityType!)}'"); + if (!string.IsNullOrEmpty(filters.EntityId)) + clauses.Add($"EntityId eq '{EscapeOData(filters.EntityId!)}'"); + } + internal static string BuildAuditLogFilterWithRowKeyBound( - string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, bool excludeDeletions) + string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, bool excludeDeletions, + AuditLogQueryFilters? filters = null) { var clauses = new List { @@ -316,6 +340,7 @@ namespace AutopilotMonitor.Functions.Services }; if (excludeDeletions) clauses.Add(DeletionExclusionClause()); + AppendAuditFieldFilters(clauses, filters); if (!string.IsNullOrEmpty(lastRowKey)) clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'"); if (dateFrom.HasValue) @@ -341,7 +366,8 @@ namespace AutopilotMonitor.Functions.Services // sortable; the user-defined "Timestamp" property is set by LogAuditEntry // for parity with the model. We filter on the system Timestamp since it // is always indexed. - internal static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo, bool excludeDeletions = false) + internal static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo, + bool excludeDeletions = false, AuditLogQueryFilters? filters = null) { var clauses = new List { @@ -351,6 +377,7 @@ namespace AutopilotMonitor.Functions.Services { clauses.Add(DeletionExclusionClause()); } + AppendAuditFieldFilters(clauses, filters); if (!string.IsNullOrEmpty(tenantId)) { clauses.Add($"PartitionKey eq '{tenantId}'"); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index a8d29160..1895b87a 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -426,28 +426,35 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { 'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' + (ga ? 'Omit tenantId for cross-tenant audit log (Global Admin). ' : '') + 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'Narrow further with exact-match field filters — action (e.g. "config_updated", "device_blocked"), performedBy (actor UPN), ' + + 'entityType (e.g. "TenantConfiguration", "Device"), entityId (the affected entity\'s id). All are applied server-side and ' + + 'are case-sensitive equality matches; combine them to answer questions like "every action alice@contoso.com took on this device". ' + 'Pagination: when "nextLink" is present in the response, more entries are available — call this tool again and pass the ' + 'whole nextLink string (e.g. "/api/global/audit/logs?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + - '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + - 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', + '"continuation". The tool follows it verbatim so the backend-defaulted date window AND any field filters round-trip ' + + 'correctly (otherwise a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', inputSchema: { tenantId: z.string().optional().describe(ga ? 'Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the audit window.'), dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the audit window.'), + action: z.string().optional().describe('Exact-match filter on the action (e.g. "config_updated", "device_blocked", "deletion_started").'), + performedBy: z.string().optional().describe('Exact-match filter on the actor UPN that performed the action (e.g. "alice@contoso.com").'), + entityType: z.string().optional().describe('Exact-match filter on the affected entity type (e.g. "TenantConfiguration", "Device", "User").'), + entityId: z.string().optional().describe('Exact-match filter on the affected entity id (e.g. a tenantId, deviceId, or report id).'), pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) .describe('Page size (1-1000, default 200). Returns this many entries per call; follow nextLink to fetch more.'), continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo and field filters) round-trip correctly.'), }, annotations: READ_ONLY, }, async (args) => withToolTelemetry('get_audit_logs', async () => { try { - const { tenantId, dateFrom, dateTo, pageSize, continuation } = args; + const { tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args; const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs'); const path = followNextLink( basePath, - { tenantId, dateFrom, dateTo, pageSize }, + { tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize }, continuation, ); const data = await apiFetch(path); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs index 647daac3..322b5848 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -23,10 +23,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// No row cap — callers passing very wide windows MUST consider /// to bound memory. /// - Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null); + Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null); /// Cross-tenant variant of (Global Admin only). - Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null); + Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, + AuditLogQueryFilters? filters = null); /// /// Reads a single page of audit log entries for @@ -41,12 +43,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// Task> GetAuditLogsPageAsync( string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false); + bool excludeDeletions = false, AuditLogQueryFilters? filters = null); /// Cross-tenant variant of (Global Admin only). Task> GetAllAuditLogsPageAsync( DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, - bool excludeDeletions = false); + bool excludeDeletions = false, AuditLogQueryFilters? filters = null); // --- Data Retention Queries --- Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate); @@ -96,6 +98,35 @@ namespace AutopilotMonitor.Shared.DataAccess public int EventCount { get; set; } } + /// + /// Optional exact-match field filters for audit-log queries. Each non-empty + /// value is folded into the server-side OData filter (and the pagination + /// fingerprint by the calling function), so a filtered query never falls back + /// to in-memory scanning and pagination tokens stay bound to the filter set. + /// All matches are case-sensitive equality on the stored column. + /// + public class AuditLogQueryFilters + { + /// Exact match on the Action column (e.g. "config_updated", "device_blocked"). + public string? Action { get; set; } + + /// Exact match on the PerformedBy column (the actor UPN). + public string? PerformedBy { get; set; } + + /// Exact match on the EntityType column (e.g. "TenantConfiguration", "Device"). + public string? EntityType { get; set; } + + /// Exact match on the EntityId column (the affected entity's id). + public string? EntityId { get; set; } + + /// True when no filter field is set — callers can skip the filter plumbing entirely. + public bool IsEmpty => + string.IsNullOrEmpty(Action) && + string.IsNullOrEmpty(PerformedBy) && + string.IsNullOrEmpty(EntityType) && + string.IsNullOrEmpty(EntityId); + } + public class AuditLogEntry { public string Id { get; set; } = string.Empty; diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionMcpIntegration.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionMcpIntegration.tsx index 6fb4a9af..ccba5993 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionMcpIntegration.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionMcpIntegration.tsx @@ -267,7 +267,7 @@ export function SectionMcpIntegration() { get_audit_logs - Audit trail of configuration changes, device blocks, and user management actions. Returns up to 100 most recent entries. + Audit trail of configuration changes, device blocks, and user management actions. Filterable by date window, action, actor (performedBy), entity type, and entity id; paginated.