feat(audit): multi-value filters across ManagementService, CLI and Central UI

This commit is contained in:
Joseph Doherty
2026-05-21 05:27:17 -04:00
parent 37c7a0e5ac
commit 2a76be1f94
11 changed files with 523 additions and 146 deletions
@@ -74,34 +74,21 @@ public static class AuditExportEndpoints
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
/// Unknown enum names / un-parseable Guids / dates are silently dropped
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// multi-value: a repeated query param yields a multi-element filter list, a
/// single param a one-element list. Unknown enum names / un-parseable Guids /
/// dates are silently dropped (same lax contract as
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
/// a repeated set is dropped, not the whole set.
/// </summary>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
var channels = ParseEnumList<AuditChannel>(query, "channel");
var kinds = ParseEnumList<AuditKind>(query, "kind");
var statuses = ParseEnumList<AuditStatus>(query, "status");
var sites = ParseStringList(query, "site");
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
string? site = TrimToNullable(query, "site");
string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor");
@@ -116,10 +103,10 @@ public static class AuditExportEndpoints
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channels: channel is { } c ? new[] { c } : null,
Kinds: kind is { } k ? new[] { k } : null,
Statuses: status is { } s ? new[] { s } : null,
SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
@@ -127,6 +114,53 @@ public static class AuditExportEndpoints
ToUtc: toUtc);
}
/// <summary>
/// Reads EVERY value of a (possibly repeated) query param and parses each as
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
/// <c>null</c> when the param is absent or no value parsed.
/// </summary>
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IQueryCollection query, string key)
where TEnum : struct, Enum
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var parsed = new List<TEnum>();
foreach (var raw in values)
{
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
{
parsed.Add(value);
}
}
return parsed.Count > 0 ? parsed : null;
}
/// <summary>
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
/// drops blank entries. Returns <c>null</c> when the param is absent or every
/// value was blank.
/// </summary>
private static IReadOnlyList<string>? ParseStringList(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var parsed = new List<string>();
foreach (var raw in values)
{
if (!string.IsNullOrWhiteSpace(raw))
{
parsed.Add(raw.Trim());
}
}
return parsed.Count > 0 ? parsed : null;
}
/// <summary>
/// Optional <c>maxRows=</c> query-string override. Falls back to
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable