mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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();
|
||||
|
||||
+10
-8
@@ -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
|
||||
|
||||
+14
-6
@@ -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))
|
||||
|
||||
+39
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user