mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
fix(audit): make "excl. deletions" a server-side filter that fills the page
The "All (excl. deletions)" audit view filtered deletions client-side AFTER a fixed 20-row backend page. During/after a cleanup sweep the table fills with deletion_started/deletion_completed rows (written with the operator UPN, so the existing System.Maintenance exclusion misses them); a 20-row page came back all-deletions and the client stripped it to an empty list while real entries sat on later pages. Push the exclusion into the OData query (mirroring the System.Maintenance clause) and back-fill the page: a server-side `Action ne ...` exclusion can make a single Azure page return far fewer than pageSize matches, so follow the opaque continuation until pageSize matches or the window is exhausted (accumulate whole Azure pages to keep the token gap-free; cap at 20 round-trips). The flag is bound into the continuation fingerprint scope (audit:tenant:nodel) so a token cannot be replayed across views. Threaded through interface -> repo -> storage with a defaulted param; global GA fan-out path covered too. Frontend sends excludeDeletions=true when the filter is DEFAULT and refetches page 1 on toggle. Tests: 2172/2172 backend green + 4 new AuditLogDeletionExclusionTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side "exclude deletions" audit view. Per-session deletion bookkeeping
|
||||
/// (deletion_started/deletion_completed) is written with the triggering
|
||||
/// operator's UPN — not System.Maintenance — so a cleanup sweep floods the
|
||||
/// audit table with rows the performer exclusion does not catch. The "All
|
||||
/// (excl. deletions)" view must drop these in the OData query so a page is
|
||||
/// back-filled with real entries instead of coming back empty.
|
||||
/// </summary>
|
||||
public class AuditLogDeletionExclusionTests
|
||||
{
|
||||
private static readonly Guid Tenant = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
[Fact]
|
||||
public void Tenant_filter_includes_deletion_exclusion_only_when_requested()
|
||||
{
|
||||
var without = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: false);
|
||||
var with = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
|
||||
|
||||
Assert.DoesNotContain("deletion_started", without);
|
||||
Assert.DoesNotContain("deletion_completed", without);
|
||||
|
||||
Assert.Contains("Action ne 'deletion_started'", with);
|
||||
Assert.Contains("Action ne 'deletion_completed'", with);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Global_fanout_filter_includes_deletion_exclusion_only_when_requested()
|
||||
{
|
||||
var without = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: false);
|
||||
var with = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
|
||||
Assert.DoesNotContain("deletion_started", without);
|
||||
Assert.Contains("Action ne 'deletion_started'", with);
|
||||
Assert.Contains("Action ne 'deletion_completed'", with);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exclusion_clause_is_identical_across_tenant_and_global_paths()
|
||||
{
|
||||
// Both code paths must drop the same actions; a drift would let deletion
|
||||
// noise leak back into one of the two views.
|
||||
var tenant = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
|
||||
var global = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
|
||||
var clause = TableStorageService.DeletionExclusionClause();
|
||||
Assert.Contains(clause, tenant);
|
||||
Assert.Contains(clause, global);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exclusion_does_not_disturb_the_performer_suppression()
|
||||
{
|
||||
// The pre-existing System.Maintenance suppression must survive alongside
|
||||
// the new clause — both noise sources stay filtered.
|
||||
var with = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
|
||||
Assert.Contains("PerformedBy ne 'System.Maintenance'", with);
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,8 @@ public class OffboardingSessionEnumeratorTests
|
||||
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) => throw new NotImplementedException();
|
||||
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) => 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<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();
|
||||
|
||||
+6
-4
@@ -37,12 +37,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
=> _storage.GetAllAuditLogsAsync(dateFrom, dateTo);
|
||||
|
||||
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
=> _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation);
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false)
|
||||
=> _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation, excludeDeletions);
|
||||
|
||||
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
=> _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation);
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false)
|
||||
=> _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions);
|
||||
|
||||
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
|
||||
=> _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate);
|
||||
|
||||
@@ -42,10 +42,17 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
return bad;
|
||||
}
|
||||
|
||||
// "Exclude deletions" view: drop per-session deletion bookkeeping
|
||||
// server-side so a cleanup-heavy window still fills a page with
|
||||
// real entries. Folded into the fingerprint scope so a token
|
||||
// minted for one view can't be replayed against the other.
|
||||
var excludeDeletions = string.Equals(query["excludeDeletions"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
var fingerprintScope = excludeDeletions ? "audit:tenant:nodel" : "audit:tenant";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}) for user {User}",
|
||||
"Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}, excludeDeletions={ExcludeDeletions}) for user {User}",
|
||||
tenantId, parsed.DateFrom, parsed.DateTo,
|
||||
parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null,
|
||||
parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, excludeDeletions,
|
||||
userIdentifier);
|
||||
|
||||
if (parsed.PageSize == null)
|
||||
@@ -59,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
if (!DateWindowPagination.TryAcceptContinuation(
|
||||
parsed.Continuation,
|
||||
scope: "audit:tenant",
|
||||
scope: fingerprintScope,
|
||||
callerTenantId: tenantId,
|
||||
dateFrom: parsed.DateFrom,
|
||||
dateTo: parsed.DateTo,
|
||||
@@ -79,13 +86,13 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
}
|
||||
|
||||
var page = await _maintenanceRepo.GetAuditLogsPageAsync(
|
||||
tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
|
||||
tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(page.NextRawToken))
|
||||
{
|
||||
var fp = DateWindowPagination.Fingerprint(
|
||||
scope: "audit:tenant",
|
||||
scope: fingerprintScope,
|
||||
callerTenantId: tenantId,
|
||||
dateFrom: parsed.DateFrom,
|
||||
dateTo: parsed.DateTo);
|
||||
@@ -95,7 +102,10 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
pageSize: parsed.PageSize.Value,
|
||||
wireContinuation: wireToken,
|
||||
dateFrom: parsed.DateFrom,
|
||||
dateTo: parsed.DateTo);
|
||||
dateTo: parsed.DateTo,
|
||||
extras: excludeDeletions
|
||||
? new[] { new KeyValuePair<string, string?>("excludeDeletions", "true") }
|
||||
: null);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
|
||||
+15
-8
@@ -49,17 +49,24 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
// the partition-key lookup stays cheap. Fingerprint scope is widened with the
|
||||
// filter value so cross-token reuse is rejected.
|
||||
var filterTenantId = query["tenantId"];
|
||||
var fingerprintScope = string.IsNullOrEmpty(filterTenantId)
|
||||
// "Exclude deletions" view — same semantics as the tenant endpoint. The
|
||||
// flag is folded into the fingerprint scope (so a token can't cross views)
|
||||
// and echoed on nextLink for self-contained follow-up requests.
|
||||
var excludeDeletions = string.Equals(query["excludeDeletions"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
var baseScope = string.IsNullOrEmpty(filterTenantId)
|
||||
? "audit:global"
|
||||
: $"audit:global:tenant:{filterTenantId}";
|
||||
var nextLinkExtras = string.IsNullOrEmpty(filterTenantId)
|
||||
? null
|
||||
: new[] { new KeyValuePair<string, string?>("tenantId", filterTenantId) };
|
||||
var fingerprintScope = excludeDeletions ? $"{baseScope}:nodel" : baseScope;
|
||||
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"));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}) for {User}",
|
||||
"Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}, excludeDeletions={ExcludeDeletions}) for {User}",
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all",
|
||||
filterTenantId ?? "(none)", userEmail);
|
||||
filterTenantId ?? "(none)", excludeDeletions, userEmail);
|
||||
|
||||
if (parsed.PageSize == null)
|
||||
{
|
||||
@@ -95,9 +102,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
|
||||
var page = string.IsNullOrEmpty(filterTenantId)
|
||||
? await _maintenanceRepo.GetAllAuditLogsPageAsync(
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken)
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions)
|
||||
: await _maintenanceRepo.GetAuditLogsPageAsync(
|
||||
filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
|
||||
filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(page.NextRawToken))
|
||||
|
||||
+69
-16
@@ -128,21 +128,31 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
return FetchAuditLogPageInternalAsync(BuildAuditLogFilter(tenantId, dateFrom, dateTo), pageSize, continuation);
|
||||
return FetchAuditLogPageInternalAsync(
|
||||
BuildAuditLogFilter(tenantId, dateFrom, dateTo, excludeDeletions), pageSize, continuation);
|
||||
}
|
||||
|
||||
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false)
|
||||
{
|
||||
// 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);
|
||||
return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions);
|
||||
}
|
||||
|
||||
// Upper bound on Azure round-trips per page when back-filling past
|
||||
// excluded rows. With server-side filtering each round-trip advances
|
||||
// the scan by up to ~1000 rows, so this tolerates a window of ~tens of
|
||||
// thousands of consecutive deletion entries before a page can come back
|
||||
// short (still non-empty progress — the caller just clicks Next).
|
||||
private const int AuditLogMaxFillRoundTrips = 20;
|
||||
|
||||
private async Task<RawPage<AuditLogEntry>> FetchAuditLogPageInternalAsync(
|
||||
string? filter, int pageSize, string? continuation)
|
||||
{
|
||||
@@ -150,19 +160,42 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
// RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc)
|
||||
// already yields newest-first within the partition. No re-sort needed.
|
||||
// Legacy entries (bare GUID RowKey) sort *after* all new entries because
|
||||
// '!' (0x21) precedes hex digits — they appear at the tail of the result
|
||||
// in undefined order until they age out of the date window.
|
||||
var logs = new List<AuditLogEntry>(entities.Count);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
//
|
||||
// Back-fill loop: a server-side exclusion (e.g. "exclude
|
||||
// deletions") can make a single Azure page return far fewer than
|
||||
// pageSize matches — during a cleanup sweep the first scanned
|
||||
// rows may all be deletion bookkeeping. We follow the opaque
|
||||
// continuation until we have at least pageSize matches or the
|
||||
// partition is exhausted, then return the last consumed page's
|
||||
// token. Accumulating whole Azure pages (rather than trimming to
|
||||
// pageSize) keeps that token gap-free, so we may overshoot by up
|
||||
// to one Azure page — harmless for the caller. Without an
|
||||
// exclusion the first page already fills, so the loop exits
|
||||
// immediately and behaviour is unchanged.
|
||||
var logs = new List<AuditLogEntry>(pageSize);
|
||||
string? nextRawToken = continuation;
|
||||
var roundTrips = 0;
|
||||
do
|
||||
{
|
||||
var (entities, token) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: nextRawToken);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
nextRawToken = token;
|
||||
roundTrips++;
|
||||
}
|
||||
while (logs.Count < pageSize
|
||||
&& !string.IsNullOrEmpty(nextRawToken)
|
||||
&& roundTrips < AuditLogMaxFillRoundTrips);
|
||||
|
||||
return new RawPage<AuditLogEntry>(logs, nextRawToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -173,7 +206,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
private async Task<RawPage<AuditLogEntry>> FetchAllAuditLogsPageAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
@@ -219,7 +252,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);
|
||||
var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey, excludeDeletions);
|
||||
var fetched = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: tenantFilter, maxPerPage: pageSize))
|
||||
{
|
||||
@@ -259,14 +292,30 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// and remain reachable via raw table queries.
|
||||
private const string AuditLogSuppressedPerformer = "System.Maintenance";
|
||||
|
||||
private static string BuildAuditLogFilterWithRowKeyBound(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
|
||||
// Per-session deletion bookkeeping. A single cleanup run emits one
|
||||
// `deletion_started` + one `deletion_completed` audit row per session,
|
||||
// so a retention sweep can flood the table with thousands of these.
|
||||
// They carry the triggering operator's UPN (not System.Maintenance), so
|
||||
// the performer exclusion above does not catch them. The "exclude
|
||||
// deletions" audit view drops them server-side so they never consume a
|
||||
// page slot. Keep in sync with the actions written by
|
||||
// SessionDeletionProducer.cs.
|
||||
private const string AuditLogDeletionStartedAction = "deletion_started";
|
||||
private const string AuditLogDeletionCompletedAction = "deletion_completed";
|
||||
|
||||
internal static string DeletionExclusionClause()
|
||||
=> $"Action ne '{AuditLogDeletionStartedAction}' and Action ne '{AuditLogDeletionCompletedAction}'";
|
||||
|
||||
internal static string BuildAuditLogFilterWithRowKeyBound(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, bool excludeDeletions)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
$"PartitionKey eq '{tenantId}'",
|
||||
$"PerformedBy ne '{AuditLogSuppressedPerformer}'",
|
||||
};
|
||||
if (excludeDeletions)
|
||||
clauses.Add(DeletionExclusionClause());
|
||||
if (!string.IsNullOrEmpty(lastRowKey))
|
||||
clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'");
|
||||
if (dateFrom.HasValue)
|
||||
@@ -292,12 +341,16 @@ 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.
|
||||
private static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo)
|
||||
internal static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo, bool excludeDeletions = false)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
$"PerformedBy ne '{AuditLogSuppressedPerformer}'",
|
||||
};
|
||||
if (excludeDeletions)
|
||||
{
|
||||
clauses.Add(DeletionExclusionClause());
|
||||
}
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
clauses.Add($"PartitionKey eq '{tenantId}'");
|
||||
|
||||
@@ -33,13 +33,20 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// in the given window. The returned <see cref="RawPage{T}"/> carries the
|
||||
/// underlying store's opaque continuation token; <c>null</c> when this
|
||||
/// page was the last. Items in each page are sorted newest-first.
|
||||
/// When <paramref name="excludeDeletions"/> is set, per-session deletion
|
||||
/// bookkeeping (<c>deletion_started</c>/<c>deletion_completed</c>) is
|
||||
/// filtered out server-side and the page is back-filled to up to
|
||||
/// <paramref name="pageSize"/> real entries, so a cleanup-heavy window
|
||||
/// never yields an empty page.
|
||||
/// </summary>
|
||||
Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation);
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false);
|
||||
|
||||
/// <summary>Cross-tenant variant of <see cref="GetAuditLogsPageAsync"/> (Global Admin only).</summary>
|
||||
Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation);
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false);
|
||||
|
||||
// --- Data Retention Queries ---
|
||||
Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate);
|
||||
|
||||
@@ -87,6 +87,12 @@ export default function AuditPage() {
|
||||
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
|
||||
// The DEFAULT view ("All (excl. deletions)") is resolved server-side: the
|
||||
// backend drops per-session deletion bookkeeping and back-fills the page, so
|
||||
// a cleanup-heavy window still returns a full page of real entries instead of
|
||||
// an all-deletions page that the client would strip down to nothing.
|
||||
const excludeDeletions = actionFilter === 'DEFAULT';
|
||||
|
||||
const fetchPage = useCallback(async (
|
||||
nextContinuation: string | null,
|
||||
isInitial: boolean,
|
||||
@@ -99,6 +105,7 @@ export default function AuditPage() {
|
||||
dateTo: dateToIso,
|
||||
pageSize: PAGE_SIZE,
|
||||
continuation: nextContinuation ?? undefined,
|
||||
excludeDeletions,
|
||||
};
|
||||
const endpoint = globalAdminMode ? api.audit.globalLogs(opts) : api.audit.logs(opts);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
@@ -124,7 +131,7 @@ export default function AuditPage() {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [addNotification, dateFromIso, dateToIso, getAccessToken, globalAdminMode]);
|
||||
}, [addNotification, dateFromIso, dateToIso, getAccessToken, globalAdminMode, excludeDeletions]);
|
||||
|
||||
// Initial / window-change fetch resets pagination state.
|
||||
// fetchPage is intentionally excluded from deps: its identity churns whenever
|
||||
@@ -142,7 +149,7 @@ export default function AuditPage() {
|
||||
setPageNumber(1);
|
||||
fetchPage(null, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId, globalAdminMode, dateFromIso, dateToIso]);
|
||||
}, [tenantId, globalAdminMode, dateFromIso, dateToIso, excludeDeletions]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchPage(continuation, false);
|
||||
|
||||
@@ -403,19 +403,21 @@ export const api = {
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────
|
||||
audit: {
|
||||
logs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) =>
|
||||
logs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean }) =>
|
||||
`${API_BASE_URL}/api/audit/logs${qs({
|
||||
dateFrom: opts?.dateFrom,
|
||||
dateTo: opts?.dateTo,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
excludeDeletions: opts?.excludeDeletions ? 'true' : undefined,
|
||||
})}`,
|
||||
globalLogs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) =>
|
||||
globalLogs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean }) =>
|
||||
`${API_BASE_URL}/api/global/audit/logs${qs({
|
||||
dateFrom: opts?.dateFrom,
|
||||
dateTo: opts?.dateTo,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
excludeDeletions: opts?.excludeDeletions ? 'true' : undefined,
|
||||
})}`,
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user