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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-06 11:36:02 +02:00
parent b332631d53
commit 841f1ae04c
10 changed files with 303 additions and 49 deletions
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -104,10 +104,10 @@ public class OffboardingSessionEnumeratorTests
// ── Unused for these tests ─────────────────────────────────────────────
public Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary<string, string>? details = null) => throw new NotImplementedException();
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException();
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException();
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException();
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException();
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException();
@@ -30,21 +30,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
string entityId, string performedBy, Dictionary<string, string>? details = null)
=> _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details);
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null)
=> _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo);
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null,
AuditLogQueryFilters? filters = null)
=> _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo, filters);
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null)
=> _storage.GetAllAuditLogsAsync(dateFrom, dateTo);
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null,
AuditLogQueryFilters? filters = null)
=> _storage.GetAllAuditLogsAsync(dateFrom, dateTo, filters);
public Task<RawPage<AuditLogEntry>> 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<RawPage<AuditLogEntry>> 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<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
=> _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate);
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Parses the optional audit-log field filters (<c>action</c> / <c>performedBy</c>
/// / <c>entityType</c> / <c>entityId</c>) from the request query and projects them
/// into both the storage <see cref="AuditLogQueryFilters"/> and the pagination
/// extras (key/value pairs folded into the continuation fingerprint and echoed on
/// <c>nextLink</c>). 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.
/// </summary>
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"]),
};
/// <summary>
/// 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.
/// </summary>
public static List<KeyValuePair<string, string?>> ToExtras(AuditLogQueryFilters filters)
{
var extras = new List<KeyValuePair<string, string?>>();
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<KeyValuePair<string, string?>> extras, string key, string? value)
{
if (!string.IsNullOrEmpty(value))
extras.Add(new KeyValuePair<string, string?>(key, value));
}
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
}
}
@@ -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<KeyValuePair<string, string?>>(filterExtras);
if (excludeDeletions)
nextLinkExtras.Add(new KeyValuePair<string, string?>("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<string, string?>("excludeDeletions", "true") }
: null);
extras: nextLinkExtras);
}
return await req.OkAsync(new
@@ -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<KeyValuePair<string, string?>>();
if (!string.IsNullOrEmpty(filterTenantId))
nextLinkExtras.Add(new KeyValuePair<string, string?>("tenantId", filterTenantId));
if (excludeDeletions)
nextLinkExtras.Add(new KeyValuePair<string, string?>("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))
@@ -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.
/// </summary>
public async Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null)
public async Task<List<AuditLogEntry>> 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<TableEntity>(filter: filter);
var logs = new List<AuditLogEntry>();
@@ -102,12 +103,13 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Gets audit log entries across all tenants (Global Admin Mode), optional UTC window.
/// </summary>
public async Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null)
public async Task<List<AuditLogEntry>> 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<TableEntity>()
: tableClient.QueryAsync<TableEntity>(filter: filter);
@@ -129,21 +131,21 @@ namespace AutopilotMonitor.Functions.Services
public Task<RawPage<AuditLogEntry>> 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<RawPage<AuditLogEntry>> 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<RawPage<AuditLogEntry>> 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<TableEntity>(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<string> 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<string>
{
@@ -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<string>
{
@@ -351,6 +377,7 @@ namespace AutopilotMonitor.Functions.Services
{
clauses.Add(DeletionExclusionClause());
}
AppendAuditFieldFilters(clauses, filters);
if (!string.IsNullOrEmpty(tenantId))
{
clauses.Add($"PartitionKey eq '{tenantId}'");
@@ -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);
@@ -23,10 +23,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// No row cap — callers passing very wide windows MUST consider
/// <see cref="GetAuditLogsPageAsync"/> to bound memory.
/// </summary>
Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null);
Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null,
AuditLogQueryFilters? filters = null);
/// <summary>Cross-tenant variant of <see cref="GetAuditLogsAsync"/> (Global Admin only).</summary>
Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null);
Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null,
AuditLogQueryFilters? filters = null);
/// <summary>
/// Reads a single page of audit log entries for <paramref name="tenantId"/>
@@ -41,12 +43,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
bool excludeDeletions = false);
bool excludeDeletions = false, AuditLogQueryFilters? filters = null);
/// <summary>Cross-tenant variant of <see cref="GetAuditLogsPageAsync"/> (Global Admin only).</summary>
Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
bool excludeDeletions = false);
bool excludeDeletions = false, AuditLogQueryFilters? filters = null);
// --- Data Retention Queries ---
Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate);
@@ -96,6 +98,35 @@ namespace AutopilotMonitor.Shared.DataAccess
public int EventCount { get; set; }
}
/// <summary>
/// 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.
/// </summary>
public class AuditLogQueryFilters
{
/// <summary>Exact match on the <c>Action</c> column (e.g. "config_updated", "device_blocked").</summary>
public string? Action { get; set; }
/// <summary>Exact match on the <c>PerformedBy</c> column (the actor UPN).</summary>
public string? PerformedBy { get; set; }
/// <summary>Exact match on the <c>EntityType</c> column (e.g. "TenantConfiguration", "Device").</summary>
public string? EntityType { get; set; }
/// <summary>Exact match on the <c>EntityId</c> column (the affected entity's id).</summary>
public string? EntityId { get; set; }
/// <summary>True when no filter field is set — callers can skip the filter plumbing entirely.</summary>
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;
@@ -267,7 +267,7 @@ export function SectionMcpIntegration() {
</tr>
<tr>
<td className="py-2 pr-4 font-mono text-xs text-amber-700">get_audit_logs</td>
<td className="py-2 text-gray-600">Audit trail of configuration changes, device blocks, and user management actions. Returns up to 100 most recent entries.</td>
<td className="py-2 text-gray-600">Audit trail of configuration changes, device blocks, and user management actions. Filterable by date window, action, actor (performedBy), entity type, and entity id; paginated.</td>
</tr>
</tbody>
</table>