feat(audit): ParentExecutionId filter in the CLI and ManagementService

This commit is contained in:
Joseph Doherty
2026-05-21 18:59:06 -04:00
parent 9b1f78638b
commit 592cbd028e
5 changed files with 71 additions and 0 deletions

View File

@@ -60,6 +60,7 @@ public static class AuditCommands
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 executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent 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;
@@ -76,6 +77,7 @@ public static class AuditCommands
cmd.Add(actorOption); cmd.Add(actorOption);
cmd.Add(correlationIdOption); cmd.Add(correlationIdOption);
cmd.Add(executionIdOption); cmd.Add(executionIdOption);
cmd.Add(parentExecutionIdOption);
cmd.Add(errorsOnlyOption); cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption); cmd.Add(pageSizeOption);
cmd.Add(allOption); cmd.Add(allOption);
@@ -104,6 +106,7 @@ public static class AuditCommands
Actor = result.GetValue(actorOption), Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption), CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption), ExecutionId = result.GetValue(executionIdOption),
ParentExecutionId = result.GetValue(parentExecutionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption), ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption), PageSize = result.GetValue(pageSizeOption),
}; };

View File

@@ -25,6 +25,7 @@ public sealed class AuditQueryArgs
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 string? ExecutionId { get; set; }
public string? ParentExecutionId { get; set; }
public bool ErrorsOnly { get; set; } public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100; public int PageSize { get; set; } = 100;
} }
@@ -127,6 +128,7 @@ public static class AuditQueryHelpers
Add("actor", args.Actor); Add("actor", args.Actor);
Add("correlationId", args.CorrelationId); Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId); Add("executionId", args.ExecutionId);
Add("parentExecutionId", args.ParentExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue) if (afterOccurredAtUtc.HasValue)

View File

@@ -402,6 +402,13 @@ public static class AuditEndpoints
executionId = parsedExec; executionId = parsedExec;
} }
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channels: channels, Channels: channels,
Kinds: kinds, Kinds: kinds,
@@ -411,6 +418,7 @@ public static class AuditEndpoints
Actor: TrimToNullable(query, "actor"), Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId, CorrelationId: correlationId,
ExecutionId: executionId, ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: ParseUtcDate(query, "fromUtc"), FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc")); ToUtc: ParseUtcDate(query, "toUtc"));
} }

View File

@@ -66,6 +66,7 @@ public class AuditQueryCommandTests
Actor = "multi-role", Actor = "multi-role",
CorrelationId = "abc-123", CorrelationId = "abc-123",
ExecutionId = "def-456", ExecutionId = "def-456",
ParentExecutionId = "ghi-789",
ErrorsOnly = false, ErrorsOnly = false,
PageSize = 250, PageSize = 250,
}; };
@@ -83,6 +84,7 @@ public class AuditQueryCommandTests
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("def-456", parsed["executionId"]);
Assert.Equal("ghi-789", parsed["parentExecutionId"]);
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"]);
@@ -159,6 +161,7 @@ public class AuditQueryCommandTests
Assert.Null(parsed["fromUtc"]); Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]); Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]); Assert.Null(parsed["executionId"]);
Assert.Null(parsed["parentExecutionId"]);
Assert.Equal("100", parsed["pageSize"]); Assert.Equal("100", parsed["pageSize"]);
} }
@@ -173,6 +176,17 @@ public class AuditQueryCommandTests
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
} }
[Fact]
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
{
// --parent-execution-id is a single-value Guid filter — mirrors --execution-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]);
}
// ---- HTTP execution / paging ------------------------------------------ // ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler private sealed class RecordingHandler : HttpMessageHandler
@@ -308,6 +322,18 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
[Fact]
public void Query_ParentExecutionIdOption_IsAccepted()
{
// --parent-execution-id is a single-value option — mirrors --execution-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ---------------------------------- // ---- Enum-name validation (fast-fail) ----------------------------------
[Fact] [Fact]

View File

@@ -460,6 +460,38 @@ public class AuditEndpointsTests
Assert.Null(filter.ExecutionId); Assert.Null(filter.ExecutionId);
} }
[Fact]
public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
{
// parentExecutionId is a single-value Guid? filter — mirrors executionId.
var parentExecutionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = parentExecutionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(parentExecutionId, filter.ParentExecutionId);
}
[Fact]
public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) —
// mirrors the executionId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ParentExecutionId);
}
[Fact] [Fact]
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
{ {