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

View File

@@ -367,32 +367,20 @@ public static class AuditEndpoints
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
/// the same lax contract the CentralUI export endpoint uses.
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
/// multi-value: a repeated query param (<c>channel=A&amp;channel=B</c>) yields
/// a multi-element filter list, while a single param yields a one-element
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
/// unparseable value within a repeated set is dropped, not the whole set.
/// </summary>
public 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;
}
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;
}
var channels = ParseEnumList<AuditChannel>(query, "channel");
var kinds = ParseEnumList<AuditKind>(query, "kind");
var statuses = ParseEnumList<AuditStatus>(query, "status");
var sourceSiteIds = ParseStringList(query, "sourceSiteId");
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
@@ -401,13 +389,11 @@ public static class AuditEndpoints
correlationId = parsedCorr;
}
var sourceSiteId = TrimToNullable(query, "sourceSiteId");
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: sourceSiteId is { } site ? new[] { site } : null,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sourceSiteIds,
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
@@ -415,6 +401,54 @@ public static class AuditEndpoints
ToUtc: ParseUtcDate(query, "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 — so the filter
/// dimension stays unconstrained.
/// </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>
/// Parses the keyset-paging query parameters into an
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to