feat(audit): ExecutionId filter in the CLI and ManagementService
This commit is contained in:
@@ -59,6 +59,7 @@ public static class AuditCommands
|
|||||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||||
|
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
||||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||||
@@ -74,6 +75,7 @@ public static class AuditCommands
|
|||||||
cmd.Add(targetOption);
|
cmd.Add(targetOption);
|
||||||
cmd.Add(actorOption);
|
cmd.Add(actorOption);
|
||||||
cmd.Add(correlationIdOption);
|
cmd.Add(correlationIdOption);
|
||||||
|
cmd.Add(executionIdOption);
|
||||||
cmd.Add(errorsOnlyOption);
|
cmd.Add(errorsOnlyOption);
|
||||||
cmd.Add(pageSizeOption);
|
cmd.Add(pageSizeOption);
|
||||||
cmd.Add(allOption);
|
cmd.Add(allOption);
|
||||||
@@ -101,6 +103,7 @@ public static class AuditCommands
|
|||||||
Target = result.GetValue(targetOption),
|
Target = result.GetValue(targetOption),
|
||||||
Actor = result.GetValue(actorOption),
|
Actor = result.GetValue(actorOption),
|
||||||
CorrelationId = result.GetValue(correlationIdOption),
|
CorrelationId = result.GetValue(correlationIdOption),
|
||||||
|
ExecutionId = result.GetValue(executionIdOption),
|
||||||
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
||||||
PageSize = result.GetValue(pageSizeOption),
|
PageSize = result.GetValue(pageSizeOption),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class AuditQueryArgs
|
|||||||
public string? Target { get; set; }
|
public string? Target { get; set; }
|
||||||
public string? Actor { get; set; }
|
public string? Actor { get; set; }
|
||||||
public string? CorrelationId { get; set; }
|
public string? CorrelationId { get; set; }
|
||||||
|
public string? ExecutionId { get; set; }
|
||||||
public bool ErrorsOnly { get; set; }
|
public bool ErrorsOnly { get; set; }
|
||||||
public int PageSize { get; set; } = 100;
|
public int PageSize { get; set; } = 100;
|
||||||
}
|
}
|
||||||
@@ -125,6 +126,7 @@ public static class AuditQueryHelpers
|
|||||||
Add("target", args.Target);
|
Add("target", args.Target);
|
||||||
Add("actor", args.Actor);
|
Add("actor", args.Actor);
|
||||||
Add("correlationId", args.CorrelationId);
|
Add("correlationId", args.CorrelationId);
|
||||||
|
Add("executionId", args.ExecutionId);
|
||||||
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
if (afterOccurredAtUtc.HasValue)
|
if (afterOccurredAtUtc.HasValue)
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ public static class AuditExportEndpoints
|
|||||||
correlationId = parsedCorr;
|
correlationId = parsedCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Guid? executionId = null;
|
||||||
|
if (query.TryGetValue("executionId", out var execValues)
|
||||||
|
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||||
|
{
|
||||||
|
executionId = parsedExec;
|
||||||
|
}
|
||||||
|
|
||||||
DateTime? fromUtc = ParseUtcDate(query, "from");
|
DateTime? fromUtc = ParseUtcDate(query, "from");
|
||||||
DateTime? toUtc = ParseUtcDate(query, "to");
|
DateTime? toUtc = ParseUtcDate(query, "to");
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@ public static class AuditExportEndpoints
|
|||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
ExecutionId: executionId,
|
||||||
FromUtc: fromUtc,
|
FromUtc: fromUtc,
|
||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,13 @@ public static class AuditEndpoints
|
|||||||
correlationId = parsedCorr;
|
correlationId = parsedCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Guid? executionId = null;
|
||||||
|
if (query.TryGetValue("executionId", out var execValues)
|
||||||
|
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||||
|
{
|
||||||
|
executionId = parsedExec;
|
||||||
|
}
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Kinds: kinds,
|
Kinds: kinds,
|
||||||
@@ -403,6 +410,7 @@ public static class AuditEndpoints
|
|||||||
Target: TrimToNullable(query, "target"),
|
Target: TrimToNullable(query, "target"),
|
||||||
Actor: TrimToNullable(query, "actor"),
|
Actor: TrimToNullable(query, "actor"),
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
ExecutionId: executionId,
|
||||||
FromUtc: ParseUtcDate(query, "fromUtc"),
|
FromUtc: ParseUtcDate(query, "fromUtc"),
|
||||||
ToUtc: ParseUtcDate(query, "toUtc"));
|
ToUtc: ParseUtcDate(query, "toUtc"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public class AuditQueryCommandTests
|
|||||||
Target = "weather-api",
|
Target = "weather-api",
|
||||||
Actor = "multi-role",
|
Actor = "multi-role",
|
||||||
CorrelationId = "abc-123",
|
CorrelationId = "abc-123",
|
||||||
|
ExecutionId = "def-456",
|
||||||
ErrorsOnly = false,
|
ErrorsOnly = false,
|
||||||
PageSize = 250,
|
PageSize = 250,
|
||||||
};
|
};
|
||||||
@@ -81,6 +82,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("weather-api", parsed["target"]);
|
Assert.Equal("weather-api", parsed["target"]);
|
||||||
Assert.Equal("multi-role", parsed["actor"]);
|
Assert.Equal("multi-role", parsed["actor"]);
|
||||||
Assert.Equal("abc-123", parsed["correlationId"]);
|
Assert.Equal("abc-123", parsed["correlationId"]);
|
||||||
|
Assert.Equal("def-456", parsed["executionId"]);
|
||||||
Assert.Equal("250", parsed["pageSize"]);
|
Assert.Equal("250", parsed["pageSize"]);
|
||||||
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
|
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
|
||||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||||
@@ -155,9 +157,22 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Null(parsed["channel"]);
|
Assert.Null(parsed["channel"]);
|
||||||
Assert.Null(parsed["status"]);
|
Assert.Null(parsed["status"]);
|
||||||
Assert.Null(parsed["fromUtc"]);
|
Assert.Null(parsed["fromUtc"]);
|
||||||
|
Assert.Null(parsed["correlationId"]);
|
||||||
|
Assert.Null(parsed["executionId"]);
|
||||||
Assert.Equal("100", parsed["pageSize"]);
|
Assert.Equal("100", parsed["pageSize"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
|
||||||
|
{
|
||||||
|
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
|
||||||
|
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
|
||||||
|
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||||
|
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- HTTP execution / paging ------------------------------------------
|
// ---- HTTP execution / paging ------------------------------------------
|
||||||
|
|
||||||
private sealed class RecordingHandler : HttpMessageHandler
|
private sealed class RecordingHandler : HttpMessageHandler
|
||||||
@@ -281,6 +296,18 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
Assert.Empty(parse.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Query_ExecutionIdOption_IsAccepted()
|
||||||
|
{
|
||||||
|
// --execution-id is a single-value option — mirrors --correlation-id.
|
||||||
|
var root = AuditCommandTestHarness.BuildRoot();
|
||||||
|
var parse = root.Parse(new[]
|
||||||
|
{
|
||||||
|
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
|
||||||
|
});
|
||||||
|
Assert.Empty(parse.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ public class AuditExportEndpointsTests
|
|||||||
using (host)
|
using (host)
|
||||||
{
|
{
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var executionId = Guid.NewGuid().ToString();
|
||||||
var url =
|
var url =
|
||||||
"/api/centralui/audit/export?" +
|
"/api/centralui/audit/export?" +
|
||||||
"channel=ApiOutbound&" +
|
"channel=ApiOutbound&" +
|
||||||
@@ -147,6 +148,7 @@ public class AuditExportEndpointsTests
|
|||||||
"target=PaymentApi&" +
|
"target=PaymentApi&" +
|
||||||
"actor=apikey-1&" +
|
"actor=apikey-1&" +
|
||||||
$"correlationId={correlationId}&" +
|
$"correlationId={correlationId}&" +
|
||||||
|
$"executionId={executionId}&" +
|
||||||
"from=2026-05-20T00:00:00Z&" +
|
"from=2026-05-20T00:00:00Z&" +
|
||||||
"to=2026-05-20T23:59:59Z";
|
"to=2026-05-20T23:59:59Z";
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ public class AuditExportEndpointsTests
|
|||||||
f.Target == "PaymentApi" &&
|
f.Target == "PaymentApi" &&
|
||||||
f.Actor == "apikey-1" &&
|
f.Actor == "apikey-1" &&
|
||||||
f.CorrelationId == Guid.Parse(correlationId) &&
|
f.CorrelationId == Guid.Parse(correlationId) &&
|
||||||
|
f.ExecutionId == Guid.Parse(executionId) &&
|
||||||
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
|
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
|
||||||
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
|
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
|
||||||
Arg.Any<AuditLogPaging>(),
|
Arg.Any<AuditLogPaging>(),
|
||||||
@@ -195,6 +198,7 @@ public class AuditExportEndpointsTests
|
|||||||
f.Target == null &&
|
f.Target == null &&
|
||||||
f.Actor == null &&
|
f.Actor == null &&
|
||||||
f.CorrelationId == null &&
|
f.CorrelationId == null &&
|
||||||
|
f.ExecutionId == null &&
|
||||||
f.FromUtc == null &&
|
f.FromUtc == null &&
|
||||||
f.ToUtc == null),
|
f.ToUtc == null),
|
||||||
Arg.Any<AuditLogPaging>(),
|
Arg.Any<AuditLogPaging>(),
|
||||||
@@ -222,6 +226,25 @@ public class AuditExportEndpointsTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
|
||||||
|
{
|
||||||
|
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
|
||||||
|
// mirrors the correlationId parse.
|
||||||
|
var (client, repo, host) = await BuildHostAsync();
|
||||||
|
using (host)
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
_ = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
await repo.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
|
||||||
|
Arg.Any<AuditLogPaging>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test-only authentication handler that signs every request in as an Admin.
|
/// Test-only authentication handler that signs every request in as an Admin.
|
||||||
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
||||||
|
|||||||
@@ -428,6 +428,38 @@ public class AuditEndpointsTests
|
|||||||
Assert.Null(filter.Statuses);
|
Assert.Null(filter.Statuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
|
||||||
|
{
|
||||||
|
// executionId is a single-value Guid? filter — mirrors correlationId.
|
||||||
|
var executionId = Guid.NewGuid();
|
||||||
|
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||||
|
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["executionId"] = executionId.ToString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var filter = AuditEndpoints.ParseFilter(query);
|
||||||
|
|
||||||
|
Assert.Equal(executionId, filter.ExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
|
||||||
|
{
|
||||||
|
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
|
||||||
|
// mirrors the correlationId parse.
|
||||||
|
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||||
|
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["executionId"] = "not-a-guid",
|
||||||
|
});
|
||||||
|
|
||||||
|
var filter = AuditEndpoints.ParseFilter(query);
|
||||||
|
|
||||||
|
Assert.Null(filter.ExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user