From 92db56cceef3688460430c01023f33de66f43558 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:03:52 +0200 Subject: [PATCH 01/32] =?UTF-8?q?=E2=9C=A8=20Add=20AuditUser=20and=20messa?= =?UTF-8?q?ge-action=20enums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuditUserTests.cs | 17 ++++++++++++++ .../Auth/AuditUser.cs | 13 +++++++++++ .../Auth/MessageAction.cs | 22 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/AuditUser.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/MessageAction.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs new file mode 100644 index 0000000000..ff188e0f7b --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class AuditUserTests +{ + [Test] + public void Anonymous_has_sentinel_id_and_name() + { + Assert.That(AuditUser.Anonymous.Id, Is.EqualTo("anonymous")); + Assert.That(AuditUser.Anonymous.Name, Is.EqualTo("anonymous")); + Assert.That(AuditUser.AnonymousValue, Is.EqualTo("anonymous")); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/AuditUser.cs b/src/ServiceControl.Infrastructure/Auth/AuditUser.cs new file mode 100644 index 0000000000..a78685e3b0 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuditUser.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// +/// The principal an audited action is attributed to. is recorded when +/// authentication is disabled or no identified principal is present. +/// +public readonly record struct AuditUser(string Id, string Name) +{ + public const string AnonymousValue = "anonymous"; + + public static readonly AuditUser Anonymous = new(AnonymousValue, AnonymousValue); +} diff --git a/src/ServiceControl.Infrastructure/Auth/MessageAction.cs b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs new file mode 100644 index 0000000000..c620a147af --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// The kind of recoverability action being audited. Determines the ECS event.type. +public enum MessageActionKind +{ + Retry, + Archive, + Unarchive +} + +/// How the action selected the messages it acts on. +public enum MessageActionScope +{ + Single, + Batch, + Group, + Queue, + Endpoint, + All, + Range +} From c5a031cbeb621b19c6d8883fd445a14b12fabebf Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:07:23 +0200 Subject: [PATCH 02/32] =?UTF-8?q?=E2=9C=A8=20Add=20MessageActionAuditLog?= =?UTF-8?q?=20ECS=20emitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/MessageActionAuditLogTests.cs | 98 ++++++++++++++++ .../Auth/IMessageActionAuditLog.cs | 17 +++ .../Auth/MessageActionAuditLog.cs | 111 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/IMessageActionAuditLog.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs new file mode 100644 index 0000000000..936290539e --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs @@ -0,0 +1,98 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class MessageActionAuditLogTests +{ + static (RecordingLoggerProvider provider, MessageActionAuditLog log) Create() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + return (provider, new MessageActionAuditLog(factory)); + } + + [Test] + public void Operation_emits_one_entry_on_operation_category() + { + var (provider, log) = Create(); + + log.Operation(new AuditUser("alice-sub", "Alice"), MessageActionKind.Retry, + "error:recoverabilitygroups:retry", MessageActionScope.Group, resource: "group-1", count: 42, operationId: "op-1"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("category")[0].GetString(), Is.EqualTo("configuration")); + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("change")); + Assert.That(ecs.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:recoverabilitygroups:retry")); + Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success")); + Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub")); + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("scope").GetString(), Is.EqualTo("group")); + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("group-1")); + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("count").GetInt32(), Is.EqualTo(42)); + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-1")); + } + + [Test] + public void Archive_maps_to_deletion_event_type() + { + var (provider, log) = Create(); + + log.Operation(AuditUser.Anonymous, MessageActionKind.Archive, + "error:messages:archive", MessageActionScope.Single, resource: "m-1", count: 1, operationId: "op-2"); + + var ecs = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("deletion")); + Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("anonymous")); + } + + [Test] + public void MessageAction_emits_on_messages_subcategory_with_event_id_2002() + { + var (provider, log) = Create(); + + log.MessageAction(new AuditUser("bob-sub", "Bob"), MessageActionKind.Unarchive, + "error:messages:unarchive", MessageActionScope.Batch, messageId: "m-9", operationId: "op-3"); + + Assert.That(provider.EntriesFor("ServiceControl.Audit"), Is.Empty); + var entries = provider.EntriesFor("ServiceControl.Audit.Messages"); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].EventId.Id, Is.EqualTo(2002)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("message").GetProperty("id").GetString(), Is.EqualTo("m-9")); + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("change")); + } + + [Test] + public void Operation_failure_logs_as_warning() + { + var (provider, log) = Create(); + + log.Operation(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry", + MessageActionScope.All, resource: null, count: null, operationId: "op-4", success: false); + + var entry = provider.EntriesFor("ServiceControl.Audit")[0]; + Assert.That(entry.Level, Is.EqualTo(LogLevel.Warning)); + Assert.That(entry.EventId.Id, Is.EqualTo(2001)); + var ecs = JsonDocument.Parse(entry.Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); + } + + [TestCase(null, "op")] + [TestCase("", "op")] + [TestCase("error:messages:retry", null)] + [TestCase("error:messages:retry", "")] + public void Operation_throws_when_permission_or_operationId_missing(string? permission, string? operationId) + { + var (_, log) = Create(); + Assert.That( + () => log.Operation(AuditUser.Anonymous, MessageActionKind.Retry, permission!, MessageActionScope.All, null, null, operationId!), + Throws.InstanceOf()); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/IMessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/IMessageActionAuditLog.cs new file mode 100644 index 0000000000..62364020f4 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/IMessageActionAuditLog.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// +/// Records user-initiated recoverability message actions (retry / archive / unarchive) as structured +/// audit entries. Operation-level entries answer "who did what to which resource"; per-message entries +/// record each affected message. Both are emitted on the stable ServiceControl.Audit category +/// family so SIEM sinks can collect them without coupling to the concrete type name. +/// +public interface IMessageActionAuditLog +{ + /// Records one user operation (a single click / API call), whatever its fan-out. + void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, int? count, string operationId, bool success = true); + + /// Records one affected message, correlated to its operation via . + void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true); +} diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs new file mode 100644 index 0000000000..eb08740499 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs @@ -0,0 +1,111 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +/// +/// Emits message-action audit entries as Elastic Common Schema (ECS) documents. Operation-level entries +/// go on (shared audit umbrella); per-message entries go on the +/// sub-category so operators can filter the high-volume per-message stream +/// independently through standard logging configuration. +/// +public sealed partial class MessageActionAuditLog : IMessageActionAuditLog +{ + public const string OperationCategory = AuthorizationAuditLog.AuditCategory; // "ServiceControl.Audit" + public const string MessageCategory = AuthorizationAuditLog.AuditCategory + ".Messages"; // "ServiceControl.Audit.Messages" + + // Relaxed escaping keeps the JSON readable for log sinks, matching AuthorizationAuditLog. + static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + readonly ILogger operationLogger; + readonly ILogger messageLogger; + + public MessageActionAuditLog(ILoggerFactory loggerFactory) + { + operationLogger = loggerFactory.CreateLogger(OperationCategory); + messageLogger = loggerFactory.CreateLogger(MessageCategory); + } + + public void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, int? count, string operationId, bool success = true) + { + ArgumentException.ThrowIfNullOrEmpty(permission); + ArgumentException.ThrowIfNullOrEmpty(operationId); + + var ecs = BuildEcsEvent(user, kind, permission, scope, resource, messageId: null, count, operationId, success); + + if (success) + { + LogOperation(operationLogger, ecs); + } + else + { + LogOperationFailure(operationLogger, ecs); + } + } + + public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) + { + ArgumentException.ThrowIfNullOrEmpty(permission); + ArgumentException.ThrowIfNullOrEmpty(messageId); + ArgumentException.ThrowIfNullOrEmpty(operationId); + + var ecs = BuildEcsEvent(user, kind, permission, scope, resource: null, messageId, count: null, operationId, success); + + if (success) + { + LogMessage(messageLogger, ecs); + } + else + { + LogMessageFailure(messageLogger, ecs); + } + } + + static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, string? messageId, int? count, string operationId, bool success) + { + var ecs = new Dictionary + { + ["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["event"] = new + { + kind = "event", + category = new[] { "configuration" }, + type = new[] { kind == MessageActionKind.Archive ? "deletion" : "change" }, + action = permission, + outcome = success ? "success" : "failure" + }, + ["user"] = new + { + id = user.Id, + name = user.Name + }, + ["servicecontrol"] = new + { + permission, + scope = scope.ToString().ToLowerInvariant(), + resource, + message = messageId is null ? null : new { id = messageId }, + count, + operation = new { id = operationId } + } + }; + + return JsonSerializer.Serialize(ecs, EcsJsonOptions); + } + + [LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "{AuditEvent}")] + static partial void LogOperation(ILogger logger, string auditEvent); + + [LoggerMessage(EventId = 2001, Level = LogLevel.Warning, Message = "{AuditEvent}")] + static partial void LogOperationFailure(ILogger logger, string auditEvent); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "{AuditEvent}")] + static partial void LogMessage(ILogger logger, string auditEvent); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Warning, Message = "{AuditEvent}")] + static partial void LogMessageFailure(ILogger logger, string auditEvent); +} From 757b7372a61328f1b018db0650cb33ab8f4cc65b Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:11:07 +0200 Subject: [PATCH 03/32] =?UTF-8?q?=E2=9C=A8=20Add=20ICurrentUserAccessor=20?= =?UTF-8?q?for=20audit=20identity=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/CurrentUserAccessorTests.cs | 55 +++++++++++++++++++ .../Auth/CurrentUserAccessor.cs | 29 ++++++++++ .../Auth/ICurrentUserAccessor.cs | 11 ++++ 3 files changed, 95 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/CurrentUserAccessorTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/CurrentUserAccessor.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/ICurrentUserAccessor.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/CurrentUserAccessorTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/CurrentUserAccessorTests.cs new file mode 100644 index 0000000000..0c4ee3badf --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/CurrentUserAccessorTests.cs @@ -0,0 +1,55 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class CurrentUserAccessorTests +{ + static CurrentUserAccessor Create() + { + // Default claim keys: SubjectIdClaim = "sub", SubjectNameClaim = "preferred_username". + var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), validateConfiguration: false, requireServicePulseSettings: false); + return new CurrentUserAccessor(settings); + } + + static ClaimsPrincipal Authenticated(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "test")); + + [Test] + public void Resolves_id_and_name_from_configured_claims() + { + var user = Create().Resolve(Authenticated(new Claim("sub", "alice-sub"), new Claim("preferred_username", "Alice"))); + Assert.That(user.Id, Is.EqualTo("alice-sub")); + Assert.That(user.Name, Is.EqualTo("Alice")); + } + + [Test] + public void Falls_back_to_id_when_name_claim_missing() + { + var user = Create().Resolve(Authenticated(new Claim("sub", "alice-sub"))); + Assert.That(user.Name, Is.EqualTo("alice-sub")); + } + + [Test] + public void Anonymous_when_principal_is_null() + { + Assert.That(Create().Resolve(null), Is.EqualTo(AuditUser.Anonymous)); + } + + [Test] + public void Anonymous_when_not_authenticated() + { + Assert.That(Create().Resolve(new ClaimsPrincipal(new ClaimsIdentity())), Is.EqualTo(AuditUser.Anonymous)); + } + + [Test] + public void Anonymous_when_subject_claim_absent() + { + Assert.That(Create().Resolve(Authenticated(new Claim("preferred_username", "Alice"))), Is.EqualTo(AuditUser.Anonymous)); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/CurrentUserAccessor.cs b/src/ServiceControl.Infrastructure/Auth/CurrentUserAccessor.cs new file mode 100644 index 0000000000..4b5d8208d1 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/CurrentUserAccessor.cs @@ -0,0 +1,29 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Security.Claims; + +/// +/// Reads the subject id/name from the configured OIDC claim keys (the same keys +/// PermissionVerbHandler uses). Falls back to rather than +/// throwing, so the action trail is still recorded when authentication is disabled. +/// +public sealed class CurrentUserAccessor(OpenIdConnectSettings oidcSettings) : ICurrentUserAccessor +{ + public AuditUser Resolve(ClaimsPrincipal? principal) + { + if (principal?.Identity?.IsAuthenticated != true) + { + return AuditUser.Anonymous; + } + + var id = principal.FindFirst(oidcSettings.SubjectIdClaim)?.Value; + if (string.IsNullOrEmpty(id)) + { + return AuditUser.Anonymous; + } + + var name = principal.FindFirst(oidcSettings.SubjectNameClaim)?.Value; + return new AuditUser(id, string.IsNullOrEmpty(name) ? id : name); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/ICurrentUserAccessor.cs b/src/ServiceControl.Infrastructure/Auth/ICurrentUserAccessor.cs new file mode 100644 index 0000000000..a6093fa4ed --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/ICurrentUserAccessor.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Security.Claims; + +/// Resolves the audited from the current request principal. +public interface ICurrentUserAccessor +{ + /// Returns the principal's subject id/name, or when there is no identified principal. + AuditUser Resolve(ClaimsPrincipal? principal); +} From 8aa8747daae9c0ccc1ef917aa094616ad7f2b98a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:14:26 +0200 Subject: [PATCH 04/32] =?UTF-8?q?=E2=9C=A8=20Add=20AuditHeaders=20identity?= =?UTF-8?q?=20stamp/read=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuditHeadersTests.cs | 41 +++++++++++++++++++ ...ServiceControl.Infrastructure.Tests.csproj | 1 + .../Auth/AuditHeaders.cs | 34 +++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs new file mode 100644 index 0000000000..b77dcd5533 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs @@ -0,0 +1,41 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Collections.Generic; +using NServiceBus; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class AuditHeadersTests +{ + [Test] + public void Stamp_writes_id_and_name_headers() + { + var options = new SendOptions(); + AuditHeaders.Stamp(options, new AuditUser("alice-sub", "Alice")); + + var headers = options.GetHeaders(); + Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo("alice-sub")); + Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo("Alice")); + } + + [Test] + public void Read_round_trips_stamped_identity() + { + var headers = new Dictionary + { + [AuditHeaders.SubjectId] = "alice-sub", + [AuditHeaders.SubjectName] = "Alice" + }; + + Assert.That(AuditHeaders.Read(headers), Is.EqualTo(new AuditUser("alice-sub", "Alice"))); + } + + [Test] + public void Read_returns_anonymous_when_headers_absent() + { + Assert.That(AuditHeaders.Read(new Dictionary()), Is.EqualTo(AuditUser.Anonymous)); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 4cddac2b26..7fd5688be2 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs new file mode 100644 index 0000000000..d97c9ea69f --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs @@ -0,0 +1,34 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using NServiceBus; + +/// +/// Carries the initiating principal on ServiceControl's own internal command messages so asynchronous +/// handlers can attribute per-message actions. Trusted as-is (trusted-subsystem model): the integrity +/// rests on transport access control, consistent with how the command itself is already trusted. This +/// type is the single stamp/read choke point — cryptographic signing would be added here. +/// +public static class AuditHeaders +{ + public const string SubjectId = "ServiceControl.Audit.InitiatedBy.Id"; + public const string SubjectName = "ServiceControl.Audit.InitiatedBy.Name"; + + public static void Stamp(SendOptions options, AuditUser user) + { + options.SetHeader(SubjectId, user.Id); + options.SetHeader(SubjectName, user.Name); + } + + public static AuditUser Read(IReadOnlyDictionary headers) + { + if (headers.TryGetValue(SubjectId, out var id) && !string.IsNullOrEmpty(id)) + { + headers.TryGetValue(SubjectName, out var name); + return new AuditUser(id, string.IsNullOrEmpty(name) ? id : name); + } + + return AuditUser.Anonymous; + } +} From 9280becc3227a52d18045b64e88d33e4b43147a1 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:17:02 +0200 Subject: [PATCH 05/32] =?UTF-8?q?=E2=9C=A8=20Register=20message-action=20a?= =?UTF-8?q?udit=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/PermissionAuthorizationExtensions.cs | 5 ++++ .../Auth/AuditServiceRegistrationTests.cs | 29 +++++++++++++++++++ ...ServiceControl.Infrastructure.Tests.csproj | 1 + 3 files changed, 35 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 534ac5d95b..f19f16744d 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -50,6 +50,11 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h services.AddSingleton(); services.AddSingleton(); + // Message-action audit trail. Registered unconditionally (independent of OIDC being enabled) so + // the action trail is recorded even without authentication, attributed to AuditUser.Anonymous. + services.AddSingleton(); + services.AddSingleton(); + // Backs the my/routes manifest: a singleton table projected from the wired endpoints. Reuses // the EndpointDataSource the framework registers, so it sees exactly the routes that are served. services.AddSingleton(); diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs new file mode 100644 index 0000000000..d3acbf8145 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs @@ -0,0 +1,29 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Hosting.Auth; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class AuditServiceRegistrationTests +{ + [Test] + public void Registers_message_action_audit_and_user_accessor() + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(LoggerFactory.Create(_ => { })); + var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), validateConfiguration: false, requireServicePulseSettings: false); + + builder.AddServiceControlAuthorization(settings); + + using var provider = builder.Services.BuildServiceProvider(); + Assert.That(provider.GetService(), Is.TypeOf()); + Assert.That(provider.GetService(), Is.TypeOf()); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 7fd5688be2..e8d3871d15 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -7,6 +7,7 @@ + From fb04b9103848ff9abfdb67721e50993175515472 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:19:39 +0200 Subject: [PATCH 06/32] =?UTF-8?q?=E2=9C=85=20Guard=20that=20ServiceControl?= =?UTF-8?q?.Audit.Messages=20routes=20to=20the=20audit=20sink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LoggingConfiguratorTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs index cbc790d9de..f500c518a6 100644 --- a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs @@ -105,4 +105,14 @@ public void Audit_decisions_render_as_valid_structured_json() Assert.That(deny.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null), "absent resource should be JSON null"); }); } + + [Test] + public void Message_action_subcategory_is_captured_by_the_audit_rule() + { + var config = BuildConfig(); + var auditRule = config.LoggingRules.Single(r => r.LoggerNamePattern == AuditPattern); + + Assert.That(auditRule.NameMatches(ServiceControl.Infrastructure.Auth.MessageActionAuditLog.MessageCategory), Is.True); + Assert.That(auditRule.NameMatches(ServiceControl.Infrastructure.Auth.MessageActionAuditLog.OperationCategory), Is.True); + } } From 3bdbb51a976a5beee9c90731b879e8674862d260 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:24:09 +0200 Subject: [PATCH 07/32] =?UTF-8?q?=E2=9C=A8=20Audit=20group-retry=20operati?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FailureGroupsRetryControllerAuditTests.cs | 36 +++++++++++++++++++ .../RecordingMessageActionAuditLog.cs | 20 +++++++++++ .../Recoverability/StubCurrentUserAccessor.cs | 10 ++++++ .../API/FailureGroupsRetryController.cs | 10 +++++- 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs create mode 100644 src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs create mode 100644 src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs new file mode 100644 index 0000000000..5ab0afea8a --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs @@ -0,0 +1,36 @@ +namespace ServiceControl.UnitTests.Recoverability +{ + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.Recoverability; + using ServiceControl.Recoverability.API; + using ServiceControl.UnitTests.Operations; + + [TestFixture] + public class FailureGroupsRetryControllerAuditTests + { + [Test] + public async Task Emits_group_retry_operation_entry() + { + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var user = new AuditUser("alice-sub", "Alice"); + var retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); + var controller = new FailureGroupsRetryController(session, retryingManager, new StubCurrentUserAccessor(user), audit); + + await controller.ArchiveGroupErrors("group-42"); + + Assert.That(audit.Operations, Has.Count.EqualTo(1)); + var op = audit.Operations.Single(); + Assert.That(op.User, Is.EqualTo(user)); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-42")); + Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); + } + } +} diff --git a/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs new file mode 100644 index 0000000000..9eb9020c72 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.UnitTests.Recoverability +{ + using System.Collections.Generic; + using ServiceControl.Infrastructure.Auth; + + sealed class RecordingMessageActionAuditLog : IMessageActionAuditLog + { + public List Operations { get; } = []; + public List Messages { get; } = []; + + public void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string resource, int? count, string operationId, bool success = true) => + Operations.Add(new OperationEntry(user, kind, permission, scope, resource, count, operationId, success)); + + public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) => + Messages.Add(new MessageEntry(user, kind, permission, scope, messageId, operationId, success)); + + public sealed record OperationEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string Resource, int? Count, string OperationId, bool Success); + public sealed record MessageEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string MessageId, string OperationId, bool Success); + } +} diff --git a/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs b/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs new file mode 100644 index 0000000000..0339a97f96 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.UnitTests.Recoverability +{ + using System.Security.Claims; + using ServiceControl.Infrastructure.Auth; + + sealed class StubCurrentUserAccessor(AuditUser user) : ICurrentUserAccessor + { + public AuditUser Resolve(ClaimsPrincipal principal) => user; + } +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 8ef2ec86d2..7fd85a1051 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -10,7 +10,11 @@ namespace ServiceControl.Recoverability.API [ApiController] [Route("api")] - public class FailureGroupsRetryController(IMessageSession bus, RetryingManager retryingManager) : ControllerBase + public class FailureGroupsRetryController( + IMessageSession bus, + RetryingManager retryingManager, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsRetry)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/retry")] @@ -19,6 +23,10 @@ public async Task ArchiveGroupErrors(string groupId) { var started = DateTime.UtcNow; + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, + Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, + resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + if (!retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { await retryingManager.Wait(groupId, RetryType.FailureGroup, started); From c07fd5687802c25c7426076f7b0087cfd211db03 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:29:56 +0200 Subject: [PATCH 08/32] =?UTF-8?q?=E2=9C=A8=20Audit=20group-archive=20and?= =?UTF-8?q?=20group-unarchive=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...FailureGroupsArchiveUnarchiveAuditTests.cs | 43 +++++++++++++++++++ .../Recoverability/NoopArchiveMessages.cs | 28 ++++++++++++ .../API/FailureGroupsArchiveController.cs | 11 ++++- .../API/FailureGroupsUnarchiveController.cs | 11 ++++- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs create mode 100644 src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs new file mode 100644 index 0000000000..0ecb094a83 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs @@ -0,0 +1,43 @@ +namespace ServiceControl.UnitTests.Recoverability +{ + using System.Linq; + using System.Threading.Tasks; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.Recoverability.API; + + [TestFixture] + public class FailureGroupsArchiveUnarchiveAuditTests + { + static readonly AuditUser User = new("alice-sub", "Alice"); + + [Test] + public async Task Archive_group_emits_operation_entry() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new FailureGroupsArchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); + + await controller.ArchiveGroupErrors("group-7"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-7")); + } + + [Test] + public async Task Unarchive_group_emits_operation_entry() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new FailureGroupsUnarchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); + + await controller.UnarchiveGroupErrors("group-8"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-8")); + } + } +} diff --git a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs new file mode 100644 index 0000000000..72794b18ed --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs @@ -0,0 +1,28 @@ +namespace ServiceControl.UnitTests.Recoverability +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using ServiceControl.Persistence.Recoverability; + using ServiceControl.Recoverability; + + sealed class NoopArchiveMessages : IArchiveMessages + { + public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; + + public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; + + public bool IsArchiveInProgressFor(string groupId) => false; + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + { + } + + public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + + public IEnumerable GetArchivalOperations() => []; + } +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index 69c805f199..78460da67d 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Recoverability.API { + using System; using System.Threading.Tasks; using Infrastructure.Auth; using Microsoft.AspNetCore.Authorization; @@ -9,13 +10,21 @@ [ApiController] [Route("api")] - public class FailureGroupsArchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase + public class FailureGroupsArchiveController( + IMessageSession bus, + IArchiveMessages archiver, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsArchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/archive")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, + Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, + resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index 37e2f0b6d9..c74f82503f 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Recoverability.API { + using System; using System.Threading.Tasks; using Infrastructure.Auth; using Microsoft.AspNetCore.Authorization; @@ -9,13 +10,21 @@ [ApiController] [Route("api")] - public class FailureGroupsUnarchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase + public class FailureGroupsUnarchiveController( + IMessageSession bus, + IArchiveMessages archiver, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsUnarchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/unarchive")] [HttpPost] public async Task UnarchiveGroupErrors(string groupId) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, + Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, + resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); From 70a6e2cbd7059553ad674f4cee65947179d61d66 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:35:26 +0200 Subject: [PATCH 09/32] =?UTF-8?q?=E2=9C=A8=20Audit=20direct=20retry=20oper?= =?UTF-8?q?ations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MessageFailedConverterTests.cs | 2 +- .../RetryMessagesControllerAuditTests.cs | 85 +++++++++++++++++++ .../EndpointInstanceIdClassifierTests.cs | 2 +- .../LockedHeaderModificationValidatorTests.cs | 2 +- .../Api/RetryMessagesController.cs | 20 ++++- 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs diff --git a/src/ServiceControl.UnitTests/ExternalIntegrations/MessageFailedConverterTests.cs b/src/ServiceControl.UnitTests/ExternalIntegrations/MessageFailedConverterTests.cs index e5f8988c8e..70742c55b1 100644 --- a/src/ServiceControl.UnitTests/ExternalIntegrations/MessageFailedConverterTests.cs +++ b/src/ServiceControl.UnitTests/ExternalIntegrations/MessageFailedConverterTests.cs @@ -5,7 +5,7 @@ using System.Linq; using Contracts; using Contracts.Operations; - using MessageFailures; + using ServiceControl.MessageFailures; using NUnit.Framework; using ServiceControl.Operations; using ServiceControl.Recoverability.ExternalIntegration; diff --git a/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs new file mode 100644 index 0000000000..3f5ddfc423 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs @@ -0,0 +1,85 @@ +namespace ServiceControl.UnitTests.MessageFailures +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.MessageFailures.Api; + using ServiceControl.UnitTests.Recoverability; + using ServiceBus.Management.Infrastructure.Settings; + + [TestFixture] + public class RetryMessagesControllerAuditTests + { + static RetryMessagesController Create(TestableMessageSession session, RecordingMessageActionAuditLog audit) => + new(new Settings(), null, null, session, NullLogger.Instance, + new StubCurrentUserAccessor(new AuditUser("alice-sub", "Alice")), audit); + + [Test] + public async Task RetryMessageBy_local_emits_single_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryMessageBy(null, "msg-1"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("msg-1")); + Assert.That(op.Count, Is.EqualTo(1)); + } + + [Test] + public async Task RetryAllBy_ids_emits_batch_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllBy(["m-1", "m-2"]); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + } + + [Test] + public async Task RetryAllBy_queueAddress_emits_queue_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllBy("queue-a"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); + Assert.That(op.Resource, Is.EqualTo("queue-a")); + Assert.That(op.Count, Is.Null); + } + + [Test] + public async Task RetryAll_emits_all_scope_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAll(); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.All)); + Assert.That(op.Resource, Is.Null); + Assert.That(op.Count, Is.Null); + } + + [Test] + public async Task RetryAllByEndpoint_emits_endpoint_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllByEndpoint("endpoint-a"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Endpoint)); + Assert.That(op.Resource, Is.EqualTo("endpoint-a")); + Assert.That(op.Count, Is.Null); + } + } +} diff --git a/src/ServiceControl.UnitTests/Recoverability/EndpointInstanceIdClassifierTests.cs b/src/ServiceControl.UnitTests/Recoverability/EndpointInstanceIdClassifierTests.cs index bfca53b205..68f779b8c4 100644 --- a/src/ServiceControl.UnitTests/Recoverability/EndpointInstanceIdClassifierTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/EndpointInstanceIdClassifierTests.cs @@ -5,7 +5,7 @@ using NServiceBus; using NUnit.Framework; using ServiceControl.Recoverability; - using static MessageFailures.FailedMessage; + using static ServiceControl.MessageFailures.FailedMessage; [TestFixture] public class EndpointInstanceIdClassifierTests diff --git a/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs b/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs index 82c0646ccc..a0b78e70e5 100644 --- a/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs @@ -1,7 +1,7 @@ namespace ServiceControl.UnitTests.Recoverability { using System.Collections.Generic; - using MessageFailures.Api; + using ServiceControl.MessageFailures.Api; using NUnit.Framework; [TestFixture] diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index 29fb12966c..845674f86a 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -1,5 +1,6 @@ namespace ServiceControl.MessageFailures.Api { + using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -23,7 +24,9 @@ public class RetryMessagesController( HttpMessageInvoker httpMessageInvoker, IHttpForwarder forwarder, IMessageSession messageSession, - ILogger logger) : ControllerBase + ILogger logger, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{failedMessageId:required:minlength(1)}/retry")] @@ -32,6 +35,9 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") { if (string.IsNullOrWhiteSpace(instanceId) || instanceId == settings.InstanceId) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); return Accepted(); } @@ -62,6 +68,9 @@ public async Task RetryAllBy(List messageIds) return BadRequest(); } + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: messageIds.Count, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds.ToArray()); return Accepted(); @@ -72,6 +81,9 @@ public async Task RetryAllBy(List messageIds) [HttpPost] public async Task RetryAllBy(string queueAddress) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: queueAddress, count: null, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(m => { m.QueueAddress = queueAddress; @@ -86,6 +98,9 @@ await messageSession.SendLocal(m => [HttpPost] public async Task RetryAll() { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.All, + resource: null, count: null, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(new RequestRetryAll()); return Accepted(); @@ -96,6 +111,9 @@ public async Task RetryAll() [HttpPost] public async Task RetryAllByEndpoint(string endpointName) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Endpoint, + resource: endpointName, count: null, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName }); return Accepted(); From 44fe317e1a5e4c74cf481199c6aac3dc2d8ffef0 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:40:53 +0200 Subject: [PATCH 10/32] =?UTF-8?q?=E2=9C=A8=20Audit=20archive,=20unarchive,?= =?UTF-8?q?=20and=20pending-retry=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArchiveUnarchivePendingAuditTests.cs | 103 ++++++++++++++++++ .../Api/ArchiveMessagesController.cs | 9 +- .../Api/PendingRetryMessagesController.cs | 8 +- .../Api/UnArchiveMessagesController.cs | 8 +- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs diff --git a/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs new file mode 100644 index 0000000000..3379c55155 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs @@ -0,0 +1,103 @@ +namespace ServiceControl.UnitTests.MessageFailures +{ + using System.Linq; + using System.Threading.Tasks; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.MessageFailures.Api; + using ServiceControl.UnitTests.Recoverability; + + [TestFixture] + public class ArchiveUnarchivePendingAuditTests + { + static readonly AuditUser User = new("alice-sub", "Alice"); + static StubCurrentUserAccessor Accessor => new(User); + + [Test] + public async Task ArchiveBatch_emits_batch_archive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); + + await controller.ArchiveBatch(new[] { "m-1", "m-2", "m-3" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(3)); + } + + [Test] + public async Task Archive_single_emits_single_archive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); + + await controller.Archive("m-1"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("m-1")); + Assert.That(op.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Unarchive_ids_emits_batch_unarchive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.Unarchive(new[] { "m-1", "m-2" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + } + + [Test] + public async Task Unarchive_range_emits_range_unarchive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.Unarchive("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Range)); + Assert.That(op.Resource, Is.EqualTo("2024-01-01T00:00:00Z...2024-01-02T00:00:00Z")); + Assert.That(op.Count, Is.Null); + } + + [Test] + public async Task RetryBy_ids_emits_batch_retry_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.RetryBy(new[] { "m-1", "m-2" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + } + + [Test] + public async Task RetryBy_request_emits_queue_retry_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.RetryBy(new PendingRetryMessagesController.PendingRetryRequest { QueueAddress = "queue-a" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); + Assert.That(op.Resource, Is.EqualTo("queue-a")); + Assert.That(op.Count, Is.Null); + } + } +} diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index 84384bec6f..4b16280d60 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -1,5 +1,6 @@ namespace ServiceControl.MessageFailures.Api { + using System; using System.Linq; using System.Threading.Tasks; using Infrastructure.Auth; @@ -13,7 +14,7 @@ namespace ServiceControl.MessageFailures.Api [ApiController] [Route("api")] - public class ArchiveMessagesController(IMessageSession messageSession, IErrorMessageDataStore dataStore) : ControllerBase + public class ArchiveMessagesController(IMessageSession messageSession, IErrorMessageDataStore dataStore, ICurrentUserAccessor userAccessor, IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/archive")] @@ -27,6 +28,9 @@ public async Task ArchiveBatch(string[] messageIds) return UnprocessableEntity(ModelState); } + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, + resource: null, count: messageIds.Length, operationId: Guid.NewGuid().ToString("N")); + foreach (var id in messageIds) { var request = new ArchiveMessage { FailedMessageId = id }; @@ -55,6 +59,9 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex [HttpPatch] public async Task Archive(string messageId) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, + resource: messageId, count: 1, operationId: Guid.NewGuid().ToString("N")); + await messageSession.SendLocal(m => m.FailedMessageId = messageId); return Accepted(); diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 1ad75d3bee..5b23906da5 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -13,7 +13,7 @@ [ApiController] [Route("api")] - public class PendingRetryMessagesController(IMessageSession session) : ControllerBase + public class PendingRetryMessagesController(IMessageSession session, ICurrentUserAccessor userAccessor, IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/retry")] @@ -26,6 +26,9 @@ public async Task RetryBy(string[] ids) return UnprocessableEntity(ModelState); } + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: Guid.NewGuid().ToString("N")); + await session.SendLocal(m => m.MessageUniqueIds = ids); return Accepted(); @@ -36,6 +39,9 @@ public async Task RetryBy(string[] ids) [HttpPost] public async Task RetryBy(PendingRetryRequest request) { + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: request.QueueAddress, count: null, operationId: Guid.NewGuid().ToString("N")); + await session.SendLocal(m => { m.QueueAddress = request.QueueAddress; diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index d36940bc99..8639e6e7ce 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -12,7 +12,7 @@ [ApiController] [Route("api")] - public class UnArchiveMessagesController(IMessageSession session) : ControllerBase + public class UnArchiveMessagesController(IMessageSession session, ICurrentUserAccessor userAccessor, IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/unarchive")] @@ -24,6 +24,9 @@ public async Task Unarchive(string[] ids) return BadRequest(); } + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: Guid.NewGuid().ToString("N")); + var request = new UnArchiveMessages { FailedMessageIds = ids }; await session.SendLocal(request); @@ -48,6 +51,9 @@ public async Task Unarchive(string from, string to) return BadRequest(); } + auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, + resource: $"{from}...{to}", count: null, operationId: Guid.NewGuid().ToString("N")); + await session.SendLocal(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }); return Accepted(); From a9b6151a194b51750968554c554e5e5b6b705d1a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:45:00 +0200 Subject: [PATCH 11/32] =?UTF-8?q?=E2=9C=85=20Strengthen=20batch-audit=20te?= =?UTF-8?q?st=20assertions=20(count/resource)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MessageFailures/ArchiveUnarchivePendingAuditTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs index 3379c55155..7ea10d1eaf 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs @@ -26,6 +26,7 @@ public async Task ArchiveBatch_emits_batch_archive_operation() Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); Assert.That(op.Count, Is.EqualTo(3)); + Assert.That(op.Resource, Is.Null); } [Test] @@ -54,6 +55,8 @@ public async Task Unarchive_ids_emits_batch_unarchive_operation() var op = audit.Operations.Single(); Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + Assert.That(op.Resource, Is.Null); } [Test] @@ -83,6 +86,7 @@ public async Task RetryBy_ids_emits_batch_retry_operation() Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); Assert.That(op.Count, Is.EqualTo(2)); + Assert.That(op.Resource, Is.Null); } [Test] From c4cd4bd7ae400b492439b2597f733c9bbf8e4a57 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:48:35 +0200 Subject: [PATCH 12/32] =?UTF-8?q?=E2=9C=A8=20Audit=20per-message=20entries?= =?UTF-8?q?=20for=20batch=20id=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BatchPerMessageAuditTests.cs | 32 +++++++++++++++++++ .../Api/ArchiveMessagesController.cs | 9 ++++-- .../Api/PendingRetryMessagesController.cs | 11 +++++-- .../Api/RetryMessagesController.cs | 11 +++++-- .../Api/UnArchiveMessagesController.cs | 11 +++++-- 5 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs diff --git a/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs new file mode 100644 index 0000000000..cd47492bf3 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.UnitTests.MessageFailures +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.MessageFailures.Api; + using ServiceControl.UnitTests.Recoverability; + using ServiceBus.Management.Infrastructure.Settings; + + [TestFixture] + public class BatchPerMessageAuditTests + { + [Test] + public async Task RetryAllBy_ids_emits_one_message_entry_per_id_sharing_operation_id() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new RetryMessagesController(new Settings(), null, null, new TestableMessageSession(), + NullLogger.Instance, new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); + + await controller.RetryAllBy(["m-1", "m-2", "m-3"]); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); + var operationId = audit.Operations.Single().OperationId; + Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + } + } +} diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index 4b16280d60..d74070877f 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -28,11 +28,16 @@ public async Task ArchiveBatch(string[] messageIds) return UnprocessableEntity(ModelState); } - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, - resource: null, count: messageIds.Length, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = Guid.NewGuid().ToString("N"); + auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, + resource: null, count: messageIds.Length, operationId: operationId); foreach (var id in messageIds) { + auditLog.MessageAction(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, + MessageActionScope.Batch, messageId: id, operationId: operationId); + var request = new ArchiveMessage { FailedMessageId = id }; await messageSession.SendLocal(request); diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 5b23906da5..eedbb5a797 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -26,8 +26,15 @@ public async Task RetryBy(string[] ids) return UnprocessableEntity(ModelState); } - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, - resource: null, count: ids.Length, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = Guid.NewGuid().ToString("N"); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: operationId); + foreach (var id in ids) + { + auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, + MessageActionScope.Batch, messageId: id, operationId: operationId); + } await session.SendLocal(m => m.MessageUniqueIds = ids); diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index 845674f86a..b3b0e581cf 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -68,8 +68,15 @@ public async Task RetryAllBy(List messageIds) return BadRequest(); } - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, - resource: null, count: messageIds.Count, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = Guid.NewGuid().ToString("N"); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: messageIds.Count, operationId: operationId); + foreach (var id in messageIds) + { + auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, + MessageActionScope.Batch, messageId: id, operationId: operationId); + } await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds.ToArray()); diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index 8639e6e7ce..a015268aeb 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -24,8 +24,15 @@ public async Task Unarchive(string[] ids) return BadRequest(); } - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, - resource: null, count: ids.Length, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = Guid.NewGuid().ToString("N"); + auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: operationId); + foreach (var id in ids) + { + auditLog.MessageAction(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, + MessageActionScope.Batch, messageId: id, operationId: operationId); + } var request = new UnArchiveMessages { FailedMessageIds = ids }; From 68a5706eccb0c59c86b6e5c0499a0e17e962e02a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 18:00:51 +0200 Subject: [PATCH 13/32] =?UTF-8?q?=E2=9C=85=20File-scoped=20namespaces=20an?= =?UTF-8?q?d=20per-message=20coverage=20for=20audit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArchiveUnarchivePendingAuditTests.cs | 206 +++++++++--------- .../BatchPerMessageAuditTests.cs | 101 ++++++--- .../RetryMessagesControllerAuditTests.cs | 141 ++++++------ ...FailureGroupsArchiveUnarchiveAuditTests.cs | 78 +++---- .../FailureGroupsRetryControllerAuditTests.cs | 60 ++--- .../Recoverability/NoopArchiveMessages.cs | 38 ++-- .../RecordingMessageActionAuditLog.cs | 30 +-- .../Recoverability/StubCurrentUserAccessor.cs | 16 +- 8 files changed, 357 insertions(+), 313 deletions(-) diff --git a/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs index 7ea10d1eaf..34706994d4 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs @@ -1,107 +1,107 @@ -namespace ServiceControl.UnitTests.MessageFailures +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System.Linq; +using System.Threading.Tasks; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures.Api; +using ServiceControl.UnitTests.Recoverability; + +[TestFixture] +public class ArchiveUnarchivePendingAuditTests { - using System.Linq; - using System.Threading.Tasks; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.MessageFailures.Api; - using ServiceControl.UnitTests.Recoverability; - - [TestFixture] - public class ArchiveUnarchivePendingAuditTests + static readonly AuditUser User = new("alice-sub", "Alice"); + static StubCurrentUserAccessor Accessor => new(User); + + [Test] + public async Task ArchiveBatch_emits_batch_archive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); + + await controller.ArchiveBatch(new[] { "m-1", "m-2", "m-3" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(3)); + Assert.That(op.Resource, Is.Null); + } + + [Test] + public async Task Archive_single_emits_single_archive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); + + await controller.Archive("m-1"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("m-1")); + Assert.That(op.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Unarchive_ids_emits_batch_unarchive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.Unarchive(new[] { "m-1", "m-2" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + Assert.That(op.Resource, Is.Null); + } + + [Test] + public async Task Unarchive_range_emits_range_unarchive_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.Unarchive("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Range)); + Assert.That(op.Resource, Is.EqualTo("2024-01-01T00:00:00Z...2024-01-02T00:00:00Z")); + Assert.That(op.Count, Is.Null); + } + + [Test] + public async Task RetryBy_ids_emits_batch_retry_operation() { - static readonly AuditUser User = new("alice-sub", "Alice"); - static StubCurrentUserAccessor Accessor => new(User); - - [Test] - public async Task ArchiveBatch_emits_batch_archive_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); - - await controller.ArchiveBatch(new[] { "m-1", "m-2", "m-3" }); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); - Assert.That(op.Count, Is.EqualTo(3)); - Assert.That(op.Resource, Is.Null); - } - - [Test] - public async Task Archive_single_emits_single_archive_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new ArchiveMessagesController(new TestableMessageSession(), null, Accessor, audit); - - await controller.Archive("m-1"); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); - Assert.That(op.Resource, Is.EqualTo("m-1")); - Assert.That(op.Count, Is.EqualTo(1)); - } - - [Test] - public async Task Unarchive_ids_emits_batch_unarchive_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); - - await controller.Unarchive(new[] { "m-1", "m-2" }); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); - Assert.That(op.Count, Is.EqualTo(2)); - Assert.That(op.Resource, Is.Null); - } - - [Test] - public async Task Unarchive_range_emits_range_unarchive_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new UnArchiveMessagesController(new TestableMessageSession(), Accessor, audit); - - await controller.Unarchive("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Range)); - Assert.That(op.Resource, Is.EqualTo("2024-01-01T00:00:00Z...2024-01-02T00:00:00Z")); - Assert.That(op.Count, Is.Null); - } - - [Test] - public async Task RetryBy_ids_emits_batch_retry_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); - - await controller.RetryBy(new[] { "m-1", "m-2" }); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); - Assert.That(op.Count, Is.EqualTo(2)); - Assert.That(op.Resource, Is.Null); - } - - [Test] - public async Task RetryBy_request_emits_queue_retry_operation() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); - - await controller.RetryBy(new PendingRetryMessagesController.PendingRetryRequest { QueueAddress = "queue-a" }); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); - Assert.That(op.Resource, Is.EqualTo("queue-a")); - Assert.That(op.Count, Is.Null); - } + var audit = new RecordingMessageActionAuditLog(); + var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.RetryBy(new[] { "m-1", "m-2" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + Assert.That(op.Resource, Is.Null); + } + + [Test] + public async Task RetryBy_request_emits_queue_retry_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new PendingRetryMessagesController(new TestableMessageSession(), Accessor, audit); + + await controller.RetryBy(new PendingRetryMessagesController.PendingRetryRequest { QueueAddress = "queue-a" }); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); + Assert.That(op.Resource, Is.EqualTo("queue-a")); + Assert.That(op.Count, Is.Null); } } diff --git a/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs index cd47492bf3..cbbdeebfe9 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs @@ -1,32 +1,77 @@ -namespace ServiceControl.UnitTests.MessageFailures +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures.Api; +using ServiceControl.UnitTests.Recoverability; +using ServiceBus.Management.Infrastructure.Settings; + +[TestFixture] +public class BatchPerMessageAuditTests { - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging.Abstractions; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.MessageFailures.Api; - using ServiceControl.UnitTests.Recoverability; - using ServiceBus.Management.Infrastructure.Settings; - - [TestFixture] - public class BatchPerMessageAuditTests + [Test] + public async Task RetryAllBy_ids_emits_one_message_entry_per_id_sharing_operation_id() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new RetryMessagesController(new Settings(), null, null, new TestableMessageSession(), + NullLogger.Instance, new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); + + await controller.RetryAllBy(["m-1", "m-2", "m-3"]); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); + var operationId = audit.Operations.Single().OperationId; + Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + } + + [Test] + public async Task ArchiveBatch_emits_one_message_entry_per_id_sharing_operation_id() { - [Test] - public async Task RetryAllBy_ids_emits_one_message_entry_per_id_sharing_operation_id() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new RetryMessagesController(new Settings(), null, null, new TestableMessageSession(), - NullLogger.Instance, new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); - - await controller.RetryAllBy(["m-1", "m-2", "m-3"]); - - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); - var operationId = audit.Operations.Single().OperationId; - Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); - } + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new TestableMessageSession(), null, + new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); + + await controller.ArchiveBatch(["m-1", "m-2", "m-3"]); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); + var operationId = audit.Operations.Single().OperationId; + Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Archive)); + } + + [Test] + public async Task Unarchive_ids_emits_one_message_entry_per_id_sharing_operation_id() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new UnArchiveMessagesController(new TestableMessageSession(), + new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); + + await controller.Unarchive(["m-1", "m-2", "m-3"]); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); + var operationId = audit.Operations.Single().OperationId; + Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Unarchive)); + } + + [Test] + public async Task RetryBy_ids_emits_one_message_entry_per_id_sharing_operation_id() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new PendingRetryMessagesController(new TestableMessageSession(), + new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); + + await controller.RetryBy(["m-1", "m-2", "m-3"]); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); + var operationId = audit.Operations.Single().OperationId; + Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); } } diff --git a/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs index 3f5ddfc423..942d94927d 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs @@ -1,85 +1,84 @@ -namespace ServiceControl.UnitTests.MessageFailures +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures.Api; +using ServiceControl.UnitTests.Recoverability; +using ServiceBus.Management.Infrastructure.Settings; + +[TestFixture] +public class RetryMessagesControllerAuditTests { - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging.Abstractions; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.MessageFailures.Api; - using ServiceControl.UnitTests.Recoverability; - using ServiceBus.Management.Infrastructure.Settings; + static RetryMessagesController Create(TestableMessageSession session, RecordingMessageActionAuditLog audit) => + new(new Settings(), null, null, session, NullLogger.Instance, + new StubCurrentUserAccessor(new AuditUser("alice-sub", "Alice")), audit); - [TestFixture] - public class RetryMessagesControllerAuditTests + [Test] + public async Task RetryMessageBy_local_emits_single_operation() { - static RetryMessagesController Create(TestableMessageSession session, RecordingMessageActionAuditLog audit) => - new(new Settings(), null, null, session, NullLogger.Instance, - new StubCurrentUserAccessor(new AuditUser("alice-sub", "Alice")), audit); - - [Test] - public async Task RetryMessageBy_local_emits_single_operation() - { - var audit = new RecordingMessageActionAuditLog(); - await Create(new TestableMessageSession(), audit).RetryMessageBy(null, "msg-1"); + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryMessageBy(null, "msg-1"); - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); - Assert.That(op.Resource, Is.EqualTo("msg-1")); - Assert.That(op.Count, Is.EqualTo(1)); - } + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("msg-1")); + Assert.That(op.Count, Is.EqualTo(1)); + } - [Test] - public async Task RetryAllBy_ids_emits_batch_operation() - { - var audit = new RecordingMessageActionAuditLog(); - await Create(new TestableMessageSession(), audit).RetryAllBy(["m-1", "m-2"]); + [Test] + public async Task RetryAllBy_ids_emits_batch_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllBy(["m-1", "m-2"]); - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); - Assert.That(op.Count, Is.EqualTo(2)); - } + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Batch)); + Assert.That(op.Count, Is.EqualTo(2)); + } - [Test] - public async Task RetryAllBy_queueAddress_emits_queue_operation() - { - var audit = new RecordingMessageActionAuditLog(); - await Create(new TestableMessageSession(), audit).RetryAllBy("queue-a"); + [Test] + public async Task RetryAllBy_queueAddress_emits_queue_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllBy("queue-a"); - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); - Assert.That(op.Resource, Is.EqualTo("queue-a")); - Assert.That(op.Count, Is.Null); - } + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Queue)); + Assert.That(op.Resource, Is.EqualTo("queue-a")); + Assert.That(op.Count, Is.Null); + } - [Test] - public async Task RetryAll_emits_all_scope_operation() - { - var audit = new RecordingMessageActionAuditLog(); - await Create(new TestableMessageSession(), audit).RetryAll(); + [Test] + public async Task RetryAll_emits_all_scope_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAll(); - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.All)); - Assert.That(op.Resource, Is.Null); - Assert.That(op.Count, Is.Null); - } + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.All)); + Assert.That(op.Resource, Is.Null); + Assert.That(op.Count, Is.Null); + } - [Test] - public async Task RetryAllByEndpoint_emits_endpoint_operation() - { - var audit = new RecordingMessageActionAuditLog(); - await Create(new TestableMessageSession(), audit).RetryAllByEndpoint("endpoint-a"); + [Test] + public async Task RetryAllByEndpoint_emits_endpoint_operation() + { + var audit = new RecordingMessageActionAuditLog(); + await Create(new TestableMessageSession(), audit).RetryAllByEndpoint("endpoint-a"); - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Endpoint)); - Assert.That(op.Resource, Is.EqualTo("endpoint-a")); - Assert.That(op.Count, Is.Null); - } + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Endpoint)); + Assert.That(op.Resource, Is.EqualTo("endpoint-a")); + Assert.That(op.Count, Is.Null); } } diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs index 0ecb094a83..c9601cab8b 100644 --- a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs @@ -1,43 +1,43 @@ -namespace ServiceControl.UnitTests.Recoverability +#nullable enable +namespace ServiceControl.UnitTests.Recoverability; + +using System.Linq; +using System.Threading.Tasks; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Recoverability.API; + +[TestFixture] +public class FailureGroupsArchiveUnarchiveAuditTests { - using System.Linq; - using System.Threading.Tasks; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.Recoverability.API; - - [TestFixture] - public class FailureGroupsArchiveUnarchiveAuditTests + static readonly AuditUser User = new("alice-sub", "Alice"); + + [Test] + public async Task Archive_group_emits_operation_entry() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new FailureGroupsArchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); + + await controller.ArchiveGroupErrors("group-7"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-7")); + } + + [Test] + public async Task Unarchive_group_emits_operation_entry() { - static readonly AuditUser User = new("alice-sub", "Alice"); - - [Test] - public async Task Archive_group_emits_operation_entry() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new FailureGroupsArchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); - - await controller.ArchiveGroupErrors("group-7"); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Archive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); - Assert.That(op.Resource, Is.EqualTo("group-7")); - } - - [Test] - public async Task Unarchive_group_emits_operation_entry() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new FailureGroupsUnarchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); - - await controller.UnarchiveGroupErrors("group-8"); - - var op = audit.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); - Assert.That(op.Resource, Is.EqualTo("group-8")); - } + var audit = new RecordingMessageActionAuditLog(); + var controller = new FailureGroupsUnarchiveController(new TestableMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); + + await controller.UnarchiveGroupErrors("group-8"); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Unarchive)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-8")); } } diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs index 5ab0afea8a..c1c26dda00 100644 --- a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs @@ -1,36 +1,36 @@ -namespace ServiceControl.UnitTests.Recoverability -{ - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.Logging.Abstractions; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.Recoverability; - using ServiceControl.Recoverability.API; - using ServiceControl.UnitTests.Operations; +#nullable enable +namespace ServiceControl.UnitTests.Recoverability; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Recoverability; +using ServiceControl.Recoverability.API; +using ServiceControl.UnitTests.Operations; - [TestFixture] - public class FailureGroupsRetryControllerAuditTests +[TestFixture] +public class FailureGroupsRetryControllerAuditTests +{ + [Test] + public async Task Emits_group_retry_operation_entry() { - [Test] - public async Task Emits_group_retry_operation_entry() - { - var session = new TestableMessageSession(); - var audit = new RecordingMessageActionAuditLog(); - var user = new AuditUser("alice-sub", "Alice"); - var retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); - var controller = new FailureGroupsRetryController(session, retryingManager, new StubCurrentUserAccessor(user), audit); + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var user = new AuditUser("alice-sub", "Alice"); + var retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); + var controller = new FailureGroupsRetryController(session, retryingManager, new StubCurrentUserAccessor(user), audit); - await controller.ArchiveGroupErrors("group-42"); + await controller.ArchiveGroupErrors("group-42"); - Assert.That(audit.Operations, Has.Count.EqualTo(1)); - var op = audit.Operations.Single(); - Assert.That(op.User, Is.EqualTo(user)); - Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); - Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); - Assert.That(op.Resource, Is.EqualTo("group-42")); - Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); - } + Assert.That(audit.Operations, Has.Count.EqualTo(1)); + var op = audit.Operations.Single(); + Assert.That(op.User, Is.EqualTo(user)); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Retry)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); + Assert.That(op.Resource, Is.EqualTo("group-42")); + Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); } } diff --git a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs index 72794b18ed..2eb35a6b81 100644 --- a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs +++ b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs @@ -1,28 +1,28 @@ -namespace ServiceControl.UnitTests.Recoverability -{ - using System.Collections.Generic; - using System.Threading.Tasks; - using ServiceControl.Persistence.Recoverability; - using ServiceControl.Recoverability; +#nullable enable +namespace ServiceControl.UnitTests.Recoverability; - sealed class NoopArchiveMessages : IArchiveMessages - { - public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; +using System.Collections.Generic; +using System.Threading.Tasks; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; - public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; +sealed class NoopArchiveMessages : IArchiveMessages +{ + public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; - public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; + public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; - public bool IsArchiveInProgressFor(string groupId) => false; + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; - public void DismissArchiveOperation(string groupId, ArchiveType archiveType) - { - } + public bool IsArchiveInProgressFor(string groupId) => false; - public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + { + } - public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + public Task StartArchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; - public IEnumerable GetArchivalOperations() => []; - } + public Task StartUnarchiving(string groupId, ArchiveType archiveType) => Task.CompletedTask; + + public IEnumerable GetArchivalOperations() => []; } diff --git a/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs index 9eb9020c72..7073b15ed3 100644 --- a/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs +++ b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs @@ -1,20 +1,20 @@ -namespace ServiceControl.UnitTests.Recoverability -{ - using System.Collections.Generic; - using ServiceControl.Infrastructure.Auth; +#nullable enable +namespace ServiceControl.UnitTests.Recoverability; + +using System.Collections.Generic; +using ServiceControl.Infrastructure.Auth; - sealed class RecordingMessageActionAuditLog : IMessageActionAuditLog - { - public List Operations { get; } = []; - public List Messages { get; } = []; +sealed class RecordingMessageActionAuditLog : IMessageActionAuditLog +{ + public List Operations { get; } = []; + public List Messages { get; } = []; - public void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string resource, int? count, string operationId, bool success = true) => - Operations.Add(new OperationEntry(user, kind, permission, scope, resource, count, operationId, success)); + public void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, int? count, string operationId, bool success = true) => + Operations.Add(new OperationEntry(user, kind, permission, scope, resource, count, operationId, success)); - public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) => - Messages.Add(new MessageEntry(user, kind, permission, scope, messageId, operationId, success)); + public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) => + Messages.Add(new MessageEntry(user, kind, permission, scope, messageId, operationId, success)); - public sealed record OperationEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string Resource, int? Count, string OperationId, bool Success); - public sealed record MessageEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string MessageId, string OperationId, bool Success); - } + public sealed record OperationEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string? Resource, int? Count, string OperationId, bool Success); + public sealed record MessageEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string MessageId, string OperationId, bool Success); } diff --git a/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs b/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs index 0339a97f96..94ea9df4eb 100644 --- a/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs +++ b/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs @@ -1,10 +1,10 @@ -namespace ServiceControl.UnitTests.Recoverability -{ - using System.Security.Claims; - using ServiceControl.Infrastructure.Auth; +#nullable enable +namespace ServiceControl.UnitTests.Recoverability; + +using System.Security.Claims; +using ServiceControl.Infrastructure.Auth; - sealed class StubCurrentUserAccessor(AuditUser user) : ICurrentUserAccessor - { - public AuditUser Resolve(ClaimsPrincipal principal) => user; - } +sealed class StubCurrentUserAccessor(AuditUser user) : ICurrentUserAccessor +{ + public AuditUser Resolve(ClaimsPrincipal? principal) => user; } From 7f5f2cac3af107b407e800f1269df569cbed1827 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 3 Jul 2026 13:26:34 +0200 Subject: [PATCH 14/32] =?UTF-8?q?=E2=9C=A8=20Audit=20each=20retried/archiv?= =?UTF-8?q?ed=20message=20at=20execution=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a per-message IMessageActionAuditLog.MessageAction for every message actually retried, archived, or unarchived, attributed to the initiating user and correlated to the operation entry via an operation id. - Wire the AuditHeaders stamp/read seam into the async command pipeline; persist the initiating user + operation id on RetryBatch and the Archive/Unarchive operation documents so bulk/group operations resolved by background schedulers (and resumed after restart) stay attributed. - Emit per-message entries at the execution choke points (RetryProcessor staging, MessageArchiver batch loop, and the archive/unarchive/pending handlers) instead of at the API, so each message is logged exactly once when it is actually acted on. - Use the ASP.NET Core request id (HttpContext.TraceIdentifier) as the operation id and return it as a Request-Id response header on all three instances (exposed via CORS) so callers can correlate. --- .../Infrastructure/WebApi/Cors.cs | 2 +- .../WebApplicationExtensions.cs | 16 ++ .../Auth/AuditHeadersTests.cs | 18 +- .../Auth/AuditHeaders.cs | 15 +- .../WebApplicationExtensions.cs | 18 +- .../Archiving/ArchiveDocumentManager.cs | 7 +- .../Archiving/ArchiveOperation.cs | 7 + .../Archiving/MessageArchiver.cs | 51 ++++- .../Archiving/UnarchiveDocumentManager.cs | 7 +- .../Archiving/UnarchiveOperation.cs | 7 + .../RetryDocumentDataStore.cs | 8 +- .../ArchiveGroupPerMessageAuditTests.cs | 86 ++++++++ .../PersistenceTestBase.cs | 2 + .../RecordingMessageActionAuditLog.cs | 19 ++ .../RetryStateTests.cs | 95 ++++++++- .../IRetryDocumentDataStore.cs | 3 +- .../Archiving/IArchiveMessages.cs | 5 +- src/ServiceControl.Persistence/RetryBatch.cs | 7 + .../AsyncRangeAndQueueAuditTests.cs | 190 ++++++++++++++++++ .../BatchPerMessageAuditTests.cs | 77 ------- .../Recoverability/NoopArchiveMessages.cs | 5 +- .../WebApi/AuditOperationIdExtensions.cs | 21 ++ .../Infrastructure/WebApi/Cors.cs | 2 +- .../Api/ArchiveMessagesController.cs | 23 ++- .../Api/PendingRetryMessagesController.cs | 28 ++- .../Api/RetryMessagesController.cs | 64 ++++-- .../Api/UnArchiveMessagesController.cs | 26 ++- .../Handlers/ArchiveMessageHandler.cs | 9 +- .../UnArchiveMessagesByRangeHandler.cs | 14 +- .../Handlers/UnArchiveMessagesHandler.cs | 14 +- .../API/FailureGroupsArchiveController.cs | 13 +- .../API/FailureGroupsRetryController.cs | 15 +- .../API/FailureGroupsUnarchiveController.cs | 13 +- .../Archiving/ArchiveAllInGroupHandler.cs | 5 +- .../Archiving/UnArchiveAllInGroupHandler.cs | 5 +- .../Handlers/PendingRetriesHandler.cs | 19 +- .../Retrying/Handlers/RetriesHandler.cs | 22 +- .../Handlers/RetryAllInGroupHandler.cs | 7 +- .../Recoverability/Retrying/RetriesGateway.cs | 47 +++-- .../Recoverability/Retrying/RetryProcessor.cs | 38 ++++ .../WebApplicationExtensions.cs | 16 ++ 41 files changed, 840 insertions(+), 206 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/RecordingMessageActionAuditLog.cs create mode 100644 src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs delete mode 100644 src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs create mode 100644 src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs index 200a33f45e..d206459ad2 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs @@ -28,7 +28,7 @@ public static CorsPolicy GetDefaultPolicy(CorsSettings settings) } // Headers exposed to the client in the response (accessible via JavaScript) - builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]); + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Request-Id"]); // Headers allowed in the request from the client builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // HTTP methods allowed for cross-origin requests diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs index 76785dd77d..deab24c365 100644 --- a/src/ServiceControl.Audit/WebApplicationExtensions.cs +++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs @@ -1,7 +1,9 @@ namespace ServiceControl.Audit; +using System.Threading.Tasks; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; using ServiceControl.Infrastructure; @@ -10,6 +12,20 @@ public static class WebApplicationExtensions { public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { + // Surface the per-request id so callers can correlate and quote it. TraceIdentifier is stable + // for the request; OnStarting sets it before the response flushes. + app.Use((context, next) => + { + context.Response.OnStarting(static state => + { + var httpContext = (HttpContext)state; + httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; + return Task.CompletedTask; + }, context); + + return next(context); + }); + app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs index b77dcd5533..171e1ce318 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs @@ -11,31 +11,37 @@ namespace ServiceControl.Infrastructure.Tests.Auth; public class AuditHeadersTests { [Test] - public void Stamp_writes_id_and_name_headers() + public void Stamp_writes_id_name_and_operation_headers() { var options = new SendOptions(); - AuditHeaders.Stamp(options, new AuditUser("alice-sub", "Alice")); + AuditHeaders.Stamp(options, new AuditUser("alice-sub", "Alice"), "op-123"); var headers = options.GetHeaders(); Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo("alice-sub")); Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo("Alice")); + Assert.That(headers[AuditHeaders.OperationId], Is.EqualTo("op-123")); } [Test] - public void Read_round_trips_stamped_identity() + public void Read_round_trips_stamped_identity_and_operation() { var headers = new Dictionary { [AuditHeaders.SubjectId] = "alice-sub", - [AuditHeaders.SubjectName] = "Alice" + [AuditHeaders.SubjectName] = "Alice", + [AuditHeaders.OperationId] = "op-123" }; - Assert.That(AuditHeaders.Read(headers), Is.EqualTo(new AuditUser("alice-sub", "Alice"))); + var (user, operationId) = AuditHeaders.Read(headers); + Assert.That(user, Is.EqualTo(new AuditUser("alice-sub", "Alice"))); + Assert.That(operationId, Is.EqualTo("op-123")); } [Test] public void Read_returns_anonymous_when_headers_absent() { - Assert.That(AuditHeaders.Read(new Dictionary()), Is.EqualTo(AuditUser.Anonymous)); + var (user, operationId) = AuditHeaders.Read(new Dictionary()); + Assert.That(user, Is.EqualTo(AuditUser.Anonymous)); + Assert.That(operationId, Is.Null); } } diff --git a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs index d97c9ea69f..1469f950d9 100644 --- a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs +++ b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs @@ -14,21 +14,28 @@ public static class AuditHeaders { public const string SubjectId = "ServiceControl.Audit.InitiatedBy.Id"; public const string SubjectName = "ServiceControl.Audit.InitiatedBy.Name"; + public const string OperationId = "ServiceControl.Audit.OperationId"; - public static void Stamp(SendOptions options, AuditUser user) + public static void Stamp(SendOptions options, AuditUser user, string operationId) { options.SetHeader(SubjectId, user.Id); options.SetHeader(SubjectName, user.Name); + if (!string.IsNullOrEmpty(operationId)) + { + options.SetHeader(OperationId, operationId); + } } - public static AuditUser Read(IReadOnlyDictionary headers) + public static (AuditUser User, string? OperationId) Read(IReadOnlyDictionary headers) { + headers.TryGetValue(OperationId, out var operationId); + if (headers.TryGetValue(SubjectId, out var id) && !string.IsNullOrEmpty(id)) { headers.TryGetValue(SubjectName, out var name); - return new AuditUser(id, string.IsNullOrEmpty(name) ? id : name); + return (new AuditUser(id, string.IsNullOrEmpty(name) ? id : name), operationId); } - return AuditUser.Anonymous; + return (AuditUser.Anonymous, operationId); } } diff --git a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs index fad91eef55..58d7a7c58b 100644 --- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs +++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Monitoring.Infrastructure; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; using ServiceControl.Infrastructure; @@ -9,6 +11,20 @@ public static class WebApplicationExtensions { public static void UseServiceControlMonitoring(this WebApplication appBuilder, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, CorsSettings corsSettings) { + // Surface the per-request id so callers can correlate and quote it. TraceIdentifier is stable + // for the request; OnStarting sets it before the response flushes. + appBuilder.Use((context, next) => + { + context.Response.OnStarting(static state => + { + var httpContext = (HttpContext)state; + httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; + return Task.CompletedTask; + }, context); + + return next(context); + }); + appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings); appBuilder.UseServiceControlHttps(httpsSettings); @@ -30,7 +46,7 @@ public static void UseServiceControlMonitoring(this WebApplication appBuilder, F } // Headers exposed to the client in the response (accessible via JavaScript) - policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]); + policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Request-Id"]); // Headers allowed in the request from the client policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // HTTP methods allowed for cross-origin requests diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveDocumentManager.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveDocumentManager.cs index c83e693141..81f822c078 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveDocumentManager.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveDocumentManager.cs @@ -16,7 +16,7 @@ class ArchiveDocumentManager(ExpirationManager expirationManager, ILogger logger { public Task LoadArchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType) => session.LoadAsync(ArchiveOperation.MakeId(groupId, archiveType)); - public async Task CreateArchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType, int numberOfMessages, string groupName, int batchSize) + public async Task CreateArchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType, int numberOfMessages, string groupName, int batchSize, string initiatedById = null, string initiatedByName = null, string operationId = null) { var operation = new ArchiveOperation { @@ -28,7 +28,10 @@ public async Task CreateArchiveOperation(IAsyncDocumentSession Started = DateTime.UtcNow, GroupName = groupName, NumberOfBatches = (int)Math.Ceiling(numberOfMessages / (float)batchSize), - CurrentBatch = 0 + CurrentBatch = 0, + InitiatedById = initiatedById, + InitiatedByName = initiatedByName, + OperationId = operationId }; await session.StoreAsync(operation); diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperation.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperation.cs index 8fedc64233..d57e90d86a 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperation.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperation.cs @@ -13,6 +13,13 @@ class ArchiveOperation // raven public DateTime Started { get; set; } public int NumberOfBatches { get; set; } public int CurrentBatch { get; set; } + + // Audit attribution for the initiating operation, carried so per-message audit entries can be + // emitted (and correlated to the operation) as each batch is archived, including after a restart. + public string InitiatedById { get; set; } + public string InitiatedByName { get; set; } + public string OperationId { get; set; } + public static string MakeId(string requestId, ArchiveType archiveType) { return $"ArchiveOperations/{(int)archiveType}/{requestId}"; diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs index b2957ba9a8..56f63c0ced 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RavenDB; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.DomainEvents; using ServiceControl.Persistence.Recoverability; using ServiceControl.Recoverability; @@ -17,12 +18,14 @@ public MessageArchiver( OperationsManager operationsManager, IDomainEvents domainEvents, ExpirationManager expirationManager, + IMessageActionAuditLog auditLog, ILogger logger ) { this.sessionProvider = sessionProvider; this.domainEvents = domainEvents; this.expirationManager = expirationManager; + this.auditLog = auditLog; this.logger = logger; this.operationsManager = operationsManager; @@ -33,7 +36,7 @@ ILogger logger unarchivingManager = new UnarchivingManager(domainEvents, operationsManager); } - public async Task ArchiveAllInGroup(string groupId) + public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string operationId = null) { logger.LogInformation("Archiving of {GroupId} started", groupId); ArchiveOperation archiveOperation; @@ -54,13 +57,17 @@ public async Task ArchiveAllInGroup(string groupId) } logger.LogInformation("Splitting group {GroupId} into batches", groupId); - archiveOperation = await archiveDocumentManager.CreateArchiveOperation(session, groupId, ArchiveType.FailureGroup, groupDetails.NumberOfMessagesInGroup, groupDetails.GroupName, batchSize); + archiveOperation = await archiveDocumentManager.CreateArchiveOperation(session, groupId, ArchiveType.FailureGroup, groupDetails.NumberOfMessagesInGroup, groupDetails.GroupName, batchSize, initiatedBy?.Id, initiatedBy?.Name, operationId); await session.SaveChangesAsync(); logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, archiveOperation.NumberOfBatches); } } + // Captured from the persisted operation so resumed operations remain attributed to the initiator. + var auditUser = new AuditUser(archiveOperation.InitiatedById, archiveOperation.InitiatedByName); + var auditOperationId = archiveOperation.OperationId; + await archivingManager.StartArchiving(archiveOperation); while (archiveOperation.CurrentBatch < archiveOperation.NumberOfBatches) @@ -90,11 +97,15 @@ public async Task ArchiveAllInGroup(string groupId) if (nextBatch != null) { + // Remove `FailedMessages/` prefix and publish pure GUIDs without Raven collection name + var messageIds = nextBatch.DocumentIds.Select(id => id.Replace("FailedMessages/", "")).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchArchived { - // Remove `FailedMessages/` prefix and publish pure GUIDs without Raven collection name - FailedMessagesIds = nextBatch.DocumentIds.Select(id => id.Replace("FailedMessages/", "")).ToArray() + FailedMessagesIds = messageIds }); + + AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, messageIds); } if (nextBatch != null) @@ -125,7 +136,7 @@ await domainEvents.Raise(new FailedMessageGroupArchived logger.LogInformation("Archiving of group {GroupId} completed", groupId); } - public async Task UnarchiveAllInGroup(string groupId) + public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string operationId = null) { logger.LogInformation("Unarchiving of {GroupId} started", groupId); UnarchiveOperation unarchiveOperation; @@ -147,13 +158,17 @@ public async Task UnarchiveAllInGroup(string groupId) } logger.LogInformation("Splitting group {GroupId} into batches", groupId); - unarchiveOperation = await unarchiveDocumentManager.CreateUnarchiveOperation(session, groupId, ArchiveType.FailureGroup, groupDetails.NumberOfMessagesInGroup, groupDetails.GroupName, batchSize); + unarchiveOperation = await unarchiveDocumentManager.CreateUnarchiveOperation(session, groupId, ArchiveType.FailureGroup, groupDetails.NumberOfMessagesInGroup, groupDetails.GroupName, batchSize, initiatedBy?.Id, initiatedBy?.Name, operationId); await session.SaveChangesAsync(); logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, unarchiveOperation.NumberOfBatches); } } + // Captured from the persisted operation so resumed operations remain attributed to the initiator. + var auditUser = new AuditUser(unarchiveOperation.InitiatedById, unarchiveOperation.InitiatedByName); + var auditOperationId = unarchiveOperation.OperationId; + await unarchivingManager.StartUnarchiving(unarchiveOperation); while (unarchiveOperation.CurrentBatch < unarchiveOperation.NumberOfBatches) @@ -182,11 +197,15 @@ public async Task UnarchiveAllInGroup(string groupId) if (nextBatch != null) { + // Remove `FailedMessages/` prefix and publish pure GUIDs without Raven collection name + var messageIds = nextBatch.DocumentIds.Select(id => id.Replace("FailedMessages/", "")).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchUnarchived { - // Remove `FailedMessages/` prefix and publish pure GUIDs without Raven collection name - FailedMessagesIds = nextBatch.DocumentIds.Select(id => id.Replace("FailedMessages/", "")).ToArray() + FailedMessagesIds = messageIds }); + + AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, messageIds); } if (nextBatch != null) @@ -215,6 +234,21 @@ await domainEvents.Raise(new FailedMessageGroupUnarchived }); } + // Emits one per-message audit entry for each message in a batch, correlated to the initiating + // operation. Skipped when no OperationId was captured (e.g. legacy in-flight operations). + void AuditArchivedMessages(MessageActionKind kind, string permission, AuditUser user, string operationId, string[] messageIds) + { + if (string.IsNullOrEmpty(operationId)) + { + return; + } + + foreach (var messageId in messageIds) + { + auditLog.MessageAction(user, kind, permission, MessageActionScope.Group, messageId, operationId); + } + } + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => operationsManager.IsOperationInProgressFor(groupId, archiveType); public bool IsArchiveInProgressFor(string groupId) @@ -236,6 +270,7 @@ public IEnumerable GetArchivalOperations() readonly OperationsManager operationsManager; readonly IDomainEvents domainEvents; readonly ExpirationManager expirationManager; + readonly IMessageActionAuditLog auditLog; readonly ArchiveDocumentManager archiveDocumentManager; readonly ArchivingManager archivingManager; readonly UnarchiveDocumentManager unarchiveDocumentManager; diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveDocumentManager.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveDocumentManager.cs index 83d2e315cb..92615e5bf9 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveDocumentManager.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveDocumentManager.cs @@ -15,7 +15,7 @@ class UnarchiveDocumentManager { public Task LoadUnarchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType) => session.LoadAsync(UnarchiveOperation.MakeId(groupId, archiveType)); - public async Task CreateUnarchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType, int numberOfMessages, string groupName, int batchSize) + public async Task CreateUnarchiveOperation(IAsyncDocumentSession session, string groupId, ArchiveType archiveType, int numberOfMessages, string groupName, int batchSize, string initiatedById = null, string initiatedByName = null, string operationId = null) { var operation = new UnarchiveOperation { @@ -27,7 +27,10 @@ public async Task CreateUnarchiveOperation(IAsyncDocumentSes Started = DateTime.UtcNow, GroupName = groupName, NumberOfBatches = (int)Math.Ceiling(numberOfMessages / (float)batchSize), - CurrentBatch = 0 + CurrentBatch = 0, + InitiatedById = initiatedById, + InitiatedByName = initiatedByName, + OperationId = operationId }; await session.StoreAsync(operation); diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveOperation.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveOperation.cs index 4227ce05f5..93487f3419 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveOperation.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/UnarchiveOperation.cs @@ -13,6 +13,13 @@ class UnarchiveOperation // raven public DateTime Started { get; set; } public int NumberOfBatches { get; set; } public int CurrentBatch { get; set; } + + // Audit attribution for the initiating operation, carried so per-message audit entries can be + // emitted (and correlated to the operation) as each batch is unarchived, including after a restart. + public string InitiatedById { get; set; } + public string InitiatedByName { get; set; } + public string OperationId { get; set; } + public static string MakeId(string requestId, ArchiveType archiveType) { return $"UnarchiveOperations/{(int)archiveType}/{requestId}"; diff --git a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs index 686b77df9f..999e80ee42 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs @@ -54,7 +54,8 @@ public async Task MoveBatchToStaging(string batchDocumentId) public async Task CreateBatchDocument(string retrySessionId, string requestId, RetryType retryType, string[] failedMessageRetryIds, string originator, - DateTime startTime, DateTime? last = null, string batchName = null, string classifier = null) + DateTime startTime, DateTime? last = null, string batchName = null, string classifier = null, + string initiatedById = null, string initiatedByName = null, string operationId = null) { var batchDocumentId = RetryBatch.MakeDocumentId(Guid.NewGuid().ToString()); using var session = await sessionProvider.OpenSession(); @@ -71,7 +72,10 @@ await session.StoreAsync(new RetryBatch InitialBatchSize = failedMessageRetryIds.Length, RetrySessionId = retrySessionId, FailureRetries = failedMessageRetryIds, - Status = RetryBatchStatus.MarkingDocuments + Status = RetryBatchStatus.MarkingDocuments, + InitiatedById = initiatedById, + InitiatedByName = initiatedByName, + OperationId = operationId }); await session.SaveChangesAsync(); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs new file mode 100644 index 0000000000..f15ff84d8e --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs @@ -0,0 +1,86 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Archiving +{ + using System; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.Testing; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.MessageFailures; + using ServiceControl.Persistence.Tests.Recoverability; + using ServiceControl.Recoverability; + + [TestFixture] + class ArchiveGroupPerMessageAuditTests : RavenPersistenceTestBase + { + readonly RecordingMessageActionAuditLog audit = new(); + + public ArchiveGroupPerMessageAuditTests() => + RegisterServices = services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(audit); + }; + + [Test] + public async Task Each_archived_message_is_audited_with_the_initiating_user() + { + var groupId = "TestGroup"; + var user = new AuditUser("alice-sub", "Alice"); + const string operationId = "op-arch"; + + using (var session = DocumentStore.OpenAsyncSession()) + { + foreach (var id in new[] { "A", "B" }) + { + await session.StoreAsync(new FailedMessage + { + Id = "FailedMessages/" + id, + UniqueMessageId = id, + Status = FailedMessageStatus.Unresolved + }); + } + + await session.StoreAsync(new ArchiveBatch + { + Id = ArchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 0), + DocumentIds = ["FailedMessages/A", "FailedMessages/B"] + }); + + await session.StoreAsync(new ArchiveOperation + { + Id = ArchiveOperation.MakeId(groupId, ArchiveType.FailureGroup), + RequestId = groupId, + ArchiveType = ArchiveType.FailureGroup, + TotalNumberOfMessages = 2, + NumberOfMessagesArchived = 0, + Started = DateTime.UtcNow, + GroupName = "Test Group", + NumberOfBatches = 1, + CurrentBatch = 0, + InitiatedById = user.Id, + InitiatedByName = user.Name, + OperationId = operationId + }); + + await session.SaveChangesAsync(); + } + + var handler = ServiceProvider.GetRequiredService(); + var context = new TestableMessageHandlerContext(); + + await handler.Handle(new ArchiveAllInGroup { GroupId = groupId }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "A", "B" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(user))); + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Archive)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Group)); + } + } + } +} diff --git a/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs b/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs index fdd41695c7..b0fd60e9ac 100644 --- a/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs +++ b/src/ServiceControl.Persistence.Tests/PersistenceTestBase.cs @@ -10,6 +10,7 @@ using NUnit.Framework; using Particular.LicensingComponent.Persistence; using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.DomainEvents; using ServiceControl.Operations.BodyStorage; using ServiceControl.Persistence; @@ -44,6 +45,7 @@ public async Task SetUp() hostBuilder.Services.AddSingleton(new CriticalError((_, __) => Task.CompletedTask)); hostBuilder.Services.AddSingleton(new SettingsHolder()); hostBuilder.Services.AddSingleton(new ReceiveAddresses("fakeReceiveAddress")); + hostBuilder.Services.AddSingleton(); RegisterServices.Invoke(hostBuilder.Services); diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/RecordingMessageActionAuditLog.cs b/src/ServiceControl.Persistence.Tests/Recoverability/RecordingMessageActionAuditLog.cs new file mode 100644 index 0000000000..0506bd0e1a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/RecordingMessageActionAuditLog.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace ServiceControl.Persistence.Tests.Recoverability; + +using System.Collections.Generic; +using ServiceControl.Infrastructure.Auth; + +sealed class RecordingMessageActionAuditLog : IMessageActionAuditLog +{ + public List Messages { get; } = []; + + public void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, int? count, string operationId, bool success = true) + { + } + + public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) => + Messages.Add(new MessageEntry(user, kind, permission, scope, messageId, operationId, success)); + + public sealed record MessageEntry(AuditUser User, MessageActionKind Kind, string Permission, MessageActionScope Scope, string MessageId, string OperationId, bool Success); +} diff --git a/src/ServiceControl.Persistence.Tests/RetryStateTests.cs b/src/ServiceControl.Persistence.Tests/RetryStateTests.cs index 7ba2c8d239..8f22784e67 100644 --- a/src/ServiceControl.Persistence.Tests/RetryStateTests.cs +++ b/src/ServiceControl.Persistence.Tests/RetryStateTests.cs @@ -12,10 +12,12 @@ using NUnit.Framework; using ServiceBus.Management.Infrastructure.Settings; using ServiceControl.Contracts.Operations; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.BackgroundTasks; using ServiceControl.Infrastructure.DomainEvents; using ServiceControl.MessageFailures; using ServiceControl.Persistence; + using ServiceControl.Persistence.Tests.Recoverability; using ServiceControl.Recoverability; using ServiceControl.Transports; using static ServiceControl.Recoverability.RecoverabilityComponent; @@ -98,6 +100,7 @@ public async Task When_a_group_is_prepared_with_three_batches_and_SC_is_restarte new TestTransportCustomization()), retryManager, new Lazy(() => sender), + new RecordingMessageActionAuditLog(), NullLogger.Instance); // Needs index RetryBatches_ByStatus_ReduceInitialBatchSize @@ -124,6 +127,7 @@ public async Task When_a_group_is_prepared_with_three_batches_and_SC_is_restarte new TestTransportCustomization()), retryManager, new Lazy(() => sender), + new RecordingMessageActionAuditLog(), NullLogger.Instance); await processor.ProcessBatches(); @@ -143,7 +147,7 @@ public async Task When_a_group_is_forwarded_the_status_is_Completed() var sender = new TestSender(); var returnToSender = new TestReturnToSenderDequeuer(new ReturnToSender(ErrorStore, NullLogger.Instance), ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()); - var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), NullLogger.Instance); + var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), new RecordingMessageActionAuditLog(), NullLogger.Instance); await processor.ProcessBatches(); // mark ready await processor.ProcessBatches(); @@ -173,7 +177,7 @@ public async Task When_there_is_one_poison_message_it_is_removed_from_batch_and_ }; var returnToSender = new TestReturnToSenderDequeuer(new ReturnToSender(ErrorStore, NullLogger.Instance), ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()); - var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), NullLogger.Instance); + var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), new RecordingMessageActionAuditLog(), NullLogger.Instance); bool c; do @@ -213,7 +217,7 @@ public async Task When_a_group_has_one_batch_out_of_two_forwarded_the_status_is_ var sender = new TestSender(); - var processor = new RetryProcessor(RetryBatchesStore, domainEvents, new TestReturnToSenderDequeuer(returnToSender, ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()), retryManager, new Lazy(() => sender), NullLogger.Instance); + var processor = new RetryProcessor(RetryBatchesStore, domainEvents, new TestReturnToSenderDequeuer(returnToSender, ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()), retryManager, new Lazy(() => sender), new RecordingMessageActionAuditLog(), NullLogger.Instance); CompleteDatabaseOperation(); @@ -224,12 +228,93 @@ public async Task When_a_group_has_one_batch_out_of_two_forwarded_the_status_is_ Assert.That(status.RetryState, Is.EqualTo(RetryState.Forwarding)); } + [Test] + public async Task When_a_selection_is_staged_each_message_is_audited_as_a_batch() + { + var domainEvents = new FakeDomainEvents(); + var retryManager = new RetryingManager(domainEvents, NullLogger.Instance); + var user = new AuditUser("alice-sub", "Alice"); + const string operationId = "op-sel"; + var ids = new[] { "A", "B" }; + + var messages = ids.Select(id => new FailedMessage + { + Id = FailedMessageIdGenerator.MakeDocumentId(id), + UniqueMessageId = id, + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + AttemptedAt = DateTime.UtcNow, + MessageMetadata = [], + FailureDetails = new FailureDetails(), + Headers = [] + } + ] + }).ToArray(); + + await ErrorStore.StoreFailedMessagesForTestsOnly(messages); + CompleteDatabaseOperation(); + + var gateway = new CustomRetriesGateway(true, RetryStore, retryManager); + await gateway.StartRetryForMessageSelection(ids, user, operationId); + CompleteDatabaseOperation(); + + var audit = new RecordingMessageActionAuditLog(); + var sender = new TestSender(); + var returnToSender = new TestReturnToSenderDequeuer(new ReturnToSender(ErrorStore, NullLogger.Instance), ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()); + var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), audit, NullLogger.Instance); + + await processor.ProcessBatches(); // stage + await processor.ProcessBatches(); // forward + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(ids)); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Batch)); + } + } + + [Test] + public async Task When_a_group_is_staged_each_message_is_audited_with_the_initiating_user() + { + var domainEvents = new FakeDomainEvents(); + var retryManager = new RetryingManager(domainEvents, NullLogger.Instance); + var user = new AuditUser("alice-sub", "Alice"); + const string operationId = "op-abc"; + + await CreateAFailedMessageAndMarkAsPartOfRetryBatch(retryManager, "Test-group", true, user, operationId, "A", "B"); + + var audit = new RecordingMessageActionAuditLog(); + var sender = new TestSender(); + var returnToSender = new TestReturnToSenderDequeuer(new ReturnToSender(ErrorStore, NullLogger.Instance), ErrorStore, domainEvents, "TestEndpoint", new ErrorQueueNameCache(), new TestTransportCustomization()); + var processor = new RetryProcessor(RetryBatchesStore, domainEvents, returnToSender, retryManager, new Lazy(() => sender), audit, NullLogger.Instance); + + await processor.ProcessBatches(); // stage (emits per-message audit) + await processor.ProcessBatches(); // forward + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "A", "B" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(user))); + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Group)); + } + } + Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryManager, string groupId, bool progressToStaged, int numberOfMessages) { return CreateAFailedMessageAndMarkAsPartOfRetryBatch(retryManager, groupId, progressToStaged, Enumerable.Range(0, numberOfMessages).Select(i => Guid.NewGuid().ToString()).ToArray()); } - async Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryManager, string groupId, bool progressToStaged, params string[] messageIds) + Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryManager, string groupId, bool progressToStaged, params string[] messageIds) => + CreateAFailedMessageAndMarkAsPartOfRetryBatch(retryManager, groupId, progressToStaged, null, null, messageIds); + + async Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryManager, string groupId, bool progressToStaged, AuditUser? initiatedBy, string operationId, params string[] messageIds) { var messages = messageIds.Select(id => new FailedMessage { @@ -266,7 +351,7 @@ async Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryMa var documentManager = new CustomRetryDocumentManager(progressToStaged, RetryStore, retryManager); var gateway = new CustomRetriesGateway(progressToStaged, RetryStore, retryManager); - gateway.EnqueueRetryForFailureGroup(new RetriesGateway.RetryForFailureGroup(groupId, "Test-Context", groupType: null, DateTime.UtcNow)); + gateway.EnqueueRetryForFailureGroup(new RetriesGateway.RetryForFailureGroup(groupId, "Test-Context", groupType: null, DateTime.UtcNow, initiatedBy, operationId)); CompleteDatabaseOperation(); diff --git a/src/ServiceControl.Persistence/IRetryDocumentDataStore.cs b/src/ServiceControl.Persistence/IRetryDocumentDataStore.cs index 0071812cd7..f28b4f640f 100644 --- a/src/ServiceControl.Persistence/IRetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence/IRetryDocumentDataStore.cs @@ -15,7 +15,8 @@ public interface IRetryDocumentDataStore Task CreateBatchDocument(string retrySessionId, string requestId, RetryType retryType, string[] failedMessageRetryIds, string originator, DateTime startTime, DateTime? last = null, - string batchName = null, string classifier = null); + string batchName = null, string classifier = null, + string initiatedById = null, string initiatedByName = null, string operationId = null); Task>> QueryOrphanedBatches(string retrySessionId); Task> QueryAvailableBatches(); diff --git a/src/ServiceControl.Persistence/Recoverability/Archiving/IArchiveMessages.cs b/src/ServiceControl.Persistence/Recoverability/Archiving/IArchiveMessages.cs index a95f03faf1..763893e9bb 100644 --- a/src/ServiceControl.Persistence/Recoverability/Archiving/IArchiveMessages.cs +++ b/src/ServiceControl.Persistence/Recoverability/Archiving/IArchiveMessages.cs @@ -2,6 +2,7 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Recoverability; /// @@ -9,8 +10,8 @@ /// public interface IArchiveMessages { - Task ArchiveAllInGroup(string groupId); - Task UnarchiveAllInGroup(string groupId); + Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string operationId = null); + Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string operationId = null); bool IsOperationInProgressFor(string groupId, ArchiveType archiveType); diff --git a/src/ServiceControl.Persistence/RetryBatch.cs b/src/ServiceControl.Persistence/RetryBatch.cs index 15c8d5b43c..b4aa4806f2 100644 --- a/src/ServiceControl.Persistence/RetryBatch.cs +++ b/src/ServiceControl.Persistence/RetryBatch.cs @@ -19,6 +19,13 @@ public class RetryBatch public RetryBatchStatus Status { get; set; } public IList FailureRetries { get; set; } = []; + // Audit attribution for the initiating operation. Populated only for operations whose messages + // are resolved asynchronously (retry all/endpoint/queue/group), so the per-message audit entry + // can be emitted at the point the batch is actually staged. Null for paths audited at the API. + public string InitiatedById { get; set; } + public string InitiatedByName { get; set; } + public string OperationId { get; set; } + public static string MakeDocumentId(string messageUniqueId) => "RetryBatches/" + messageUniqueId; } } \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs new file mode 100644 index 0000000000..ffc925a526 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs @@ -0,0 +1,190 @@ +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CompositeViews.Messages; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.EventLog; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.MessageFailures.Handlers; +using ServiceControl.MessageFailures.InternalMessages; +using ServiceControl.Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; +using ServiceControl.UnitTests.Operations; +using ServiceControl.UnitTests.Recoverability; + +[TestFixture] +public class AsyncRangeAndQueueAuditTests +{ + static readonly AuditUser User = new("alice-sub", "Alice"); + + static Dictionary StampedHeaders(string operationId) => new() + { + [AuditHeaders.SubjectId] = User.Id, + [AuditHeaders.SubjectName] = User.Name, + [AuditHeaders.OperationId] = operationId + }; + + [Test] + public async Task PendingRetries_by_queue_audits_each_resolved_message() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { RetryPendingMessagesResult = ["m-1", "m-2"] }; + var handler = new PendingRetriesHandler(store, audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-q") }; + await handler.Handle(new RetryPendingMessages { QueueAddress = "q", PeriodFrom = DateTime.UtcNow, PeriodTo = DateTime.UtcNow }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(User))); + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-q")); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Queue)); + } + } + + [Test] + public async Task PendingRetries_by_ids_audits_each_message() + { + var audit = new RecordingMessageActionAuditLog(); + var handler = new PendingRetriesHandler(new StubErrorMessageDataStore(), audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-pi") }; + await handler.Handle(new RetryPendingMessagesById { MessageUniqueIds = ["m-1", "m-2"] }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-pi")); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Batch)); + } + } + + [Test] + public async Task ArchiveMessage_audits_the_archived_message() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { Status = FailedMessageStatus.Unresolved } }; + var handler = new ArchiveMessageHandler(store, new FakeDomainEvents(), audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-a") }; + await handler.Handle(new ArchiveMessage { FailedMessageId = "m-1" }, context); + + var msg = audit.Messages.Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(msg.MessageId, Is.EqualTo("m-1")); + Assert.That(msg.OperationId, Is.EqualTo("op-a")); + Assert.That(msg.Kind, Is.EqualTo(MessageActionKind.Archive)); + Assert.That(msg.Scope, Is.EqualTo(MessageActionScope.Single)); + } + } + + [Test] + public async Task ArchiveMessage_already_archived_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { Status = FailedMessageStatus.Archived } }; + var handler = new ArchiveMessageHandler(store, new FakeDomainEvents(), audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-a") }; + await handler.Handle(new ArchiveMessage { FailedMessageId = "m-1" }, context); + + Assert.That(audit.Messages, Is.Empty); + } + + [Test] + public async Task UnArchiveMessages_audits_each_message_with_bare_id() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { UnArchiveMessagesResult = ["FailedMessages/m-1", "FailedMessages/m-2"] }; + var handler = new UnArchiveMessagesHandler(store, new FakeDomainEvents(), audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-u") }; + await handler.Handle(new UnArchiveMessages { FailedMessageIds = ["m-1", "m-2"] }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-u")); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Unarchive)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Batch)); + } + } + + [Test] + public async Task Unarchive_by_range_audits_each_message_with_bare_id() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { UnArchiveByRangeResult = ["FailedMessages/m-1", "FailedMessages/m-2"] }; + var handler = new UnArchiveMessagesByRangeHandler(store, new FakeDomainEvents(), audit); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-r") }; + await handler.Handle(new UnArchiveMessagesByRange { From = DateTime.UtcNow, To = DateTime.UtcNow }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(User))); + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-r")); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Unarchive)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Range)); + } + } + + sealed class StubErrorMessageDataStore : IErrorMessageDataStore + { + public string[] RetryPendingMessagesResult { get; set; } = []; + public string[] UnArchiveByRangeResult { get; set; } = []; + public string[] UnArchiveMessagesResult { get; set; } = []; + public FailedMessage ErrorByResult { get; set; } = new(); + + public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) => Task.FromResult(RetryPendingMessagesResult); + public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) => Task.CompletedTask; + public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => Task.FromResult(UnArchiveByRangeResult); + public Task UnArchiveMessages(IEnumerable failedMessageIds) => Task.FromResult(UnArchiveMessagesResult); + public Task ErrorBy(string failedMessageId) => Task.FromResult(ErrorByResult); + public Task FailedMessageMarkAsArchived(string failedMessageId) => Task.CompletedTask; + + public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) => throw new NotImplementedException(); + public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task FailedMessagesFetch(Guid[] ids) => throw new NotImplementedException(); + public Task StoreFailedErrorImport(FailedErrorImport failure) => throw new NotImplementedException(); + public Task CreateEditFailedMessageManager() => throw new NotImplementedException(); + public Task> GetFailureGroupView(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task> GetFailureGroupsByClassifier(string classifier) => throw new NotImplementedException(); + public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) => throw new NotImplementedException(); + public Task ErrorsHead(string status, string modified, string queueAddress) => throw new NotImplementedException(); + public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo) => throw new NotImplementedException(); + public Task> ErrorsSummary() => throw new NotImplementedException(); + public Task ErrorLastBy(string failedMessageId) => throw new NotImplementedException(); + public Task CreateNotificationsManager() => throw new NotImplementedException(); + public Task EditComment(string groupId, string comment) => throw new NotImplementedException(); + public Task DeleteComment(string groupId) => throw new NotImplementedException(); + public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) => throw new NotImplementedException(); + public Task GetGroupErrorsCount(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task>> GetGroup(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task MarkMessageAsResolved(string failedMessageId) => throw new NotImplementedException(); + public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, Func processCallback) => throw new NotImplementedException(); + public Task RevertRetry(string messageUniqueId) => throw new NotImplementedException(); + public Task FetchFromFailedMessage(string uniqueMessageId) => throw new NotImplementedException(); + public Task StoreEventLogItem(EventLogItem logItem) => throw new NotImplementedException(); + public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => throw new NotImplementedException(); + } +} diff --git a/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs deleted file mode 100644 index cbbdeebfe9..0000000000 --- a/src/ServiceControl.UnitTests/MessageFailures/BatchPerMessageAuditTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -#nullable enable -namespace ServiceControl.UnitTests.MessageFailures; - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; -using NServiceBus.Testing; -using NUnit.Framework; -using ServiceControl.Infrastructure.Auth; -using ServiceControl.MessageFailures.Api; -using ServiceControl.UnitTests.Recoverability; -using ServiceBus.Management.Infrastructure.Settings; - -[TestFixture] -public class BatchPerMessageAuditTests -{ - [Test] - public async Task RetryAllBy_ids_emits_one_message_entry_per_id_sharing_operation_id() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new RetryMessagesController(new Settings(), null, null, new TestableMessageSession(), - NullLogger.Instance, new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); - - await controller.RetryAllBy(["m-1", "m-2", "m-3"]); - - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); - var operationId = audit.Operations.Single().OperationId; - Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); - } - - [Test] - public async Task ArchiveBatch_emits_one_message_entry_per_id_sharing_operation_id() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new ArchiveMessagesController(new TestableMessageSession(), null, - new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); - - await controller.ArchiveBatch(["m-1", "m-2", "m-3"]); - - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); - var operationId = audit.Operations.Single().OperationId; - Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Archive)); - } - - [Test] - public async Task Unarchive_ids_emits_one_message_entry_per_id_sharing_operation_id() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new UnArchiveMessagesController(new TestableMessageSession(), - new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); - - await controller.Unarchive(["m-1", "m-2", "m-3"]); - - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); - var operationId = audit.Operations.Single().OperationId; - Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Unarchive)); - } - - [Test] - public async Task RetryBy_ids_emits_one_message_entry_per_id_sharing_operation_id() - { - var audit = new RecordingMessageActionAuditLog(); - var controller = new PendingRetryMessagesController(new TestableMessageSession(), - new StubCurrentUserAccessor(new AuditUser("a", "a")), audit); - - await controller.RetryBy(["m-1", "m-2", "m-3"]); - - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2", "m-3" })); - var operationId = audit.Operations.Single().OperationId; - Assert.That(audit.Messages.Select(m => m.OperationId), Is.All.EqualTo(operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); - } -} diff --git a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs index 2eb35a6b81..fe19844613 100644 --- a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs +++ b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs @@ -3,14 +3,15 @@ namespace ServiceControl.UnitTests.Recoverability; using System.Collections.Generic; using System.Threading.Tasks; +using ServiceControl.Infrastructure.Auth; using ServiceControl.Persistence.Recoverability; using ServiceControl.Recoverability; sealed class NoopArchiveMessages : IArchiveMessages { - public Task ArchiveAllInGroup(string groupId) => Task.CompletedTask; + public Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => Task.CompletedTask; - public Task UnarchiveAllInGroup(string groupId) => Task.CompletedTask; + public Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => Task.CompletedTask; public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; diff --git a/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs new file mode 100644 index 0000000000..1b983ff7d2 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.Infrastructure.WebApi +{ + using System; + using Microsoft.AspNetCore.Mvc; + + static class AuditOperationIdExtensions + { + /// + /// The audit operation id that ties the synchronous operation audit entry to the asynchronous + /// per-message entries emitted while the operation is carried out. Reuses ASP.NET Core's + /// per-request TraceIdentifier so the id also equals the RequestId already attached + /// to every other log line of the request. Falls back to a GUID when there is no HttpContext + /// (e.g. unit tests invoking the controller directly). + /// + public static string AuditOperationId(this ControllerBase controller) + { + var traceIdentifier = controller.HttpContext?.TraceIdentifier; + return string.IsNullOrEmpty(traceIdentifier) ? Guid.NewGuid().ToString("N") : traceIdentifier; + } + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Cors.cs b/src/ServiceControl/Infrastructure/WebApi/Cors.cs index bb05073086..ce2c9929db 100644 --- a/src/ServiceControl/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl/Infrastructure/WebApi/Cors.cs @@ -25,7 +25,7 @@ public static CorsPolicy GetDefaultPolicy(CorsSettings settings) } // Expose custom headers that clients need to read from responses - builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition"]); + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition", "Request-Id"]); // Allow standard headers required for API requests builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // Allow all HTTP methods used by the ServiceControl API diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index d74070877f..c1f732f2fa 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -29,18 +29,17 @@ public async Task ArchiveBatch(string[] messageIds) } var user = userAccessor.Resolve(User); - var operationId = Guid.NewGuid().ToString("N"); + var operationId = this.AuditOperationId(); auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, resource: null, count: messageIds.Length, operationId: operationId); foreach (var id in messageIds) { - auditLog.MessageAction(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, - MessageActionScope.Batch, messageId: id, operationId: operationId); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - var request = new ArchiveMessage { FailedMessageId = id }; - - await messageSession.SendLocal(request); + await messageSession.Send(new ArchiveMessage { FailedMessageId = id }, sendOptions); } return Accepted(); @@ -64,10 +63,16 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex [HttpPatch] public async Task Archive(string messageId) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, - resource: messageId, count: 1, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, + resource: messageId, count: 1, operationId: operationId); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await messageSession.SendLocal(m => m.FailedMessageId = messageId); + await messageSession.Send(m => m.FailedMessageId = messageId, sendOptions); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index eedbb5a797..4c5db0c9fd 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using InternalMessages; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -27,16 +28,15 @@ public async Task RetryBy(string[] ids) } var user = userAccessor.Resolve(User); - var operationId = Guid.NewGuid().ToString("N"); + var operationId = this.AuditOperationId(); auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, resource: null, count: ids.Length, operationId: operationId); - foreach (var id in ids) - { - auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, - MessageActionScope.Batch, messageId: id, operationId: operationId); - } - await session.SendLocal(m => m.MessageUniqueIds = ids); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await session.Send(m => m.MessageUniqueIds = ids, sendOptions); return Accepted(); } @@ -46,15 +46,21 @@ public async Task RetryBy(string[] ids) [HttpPost] public async Task RetryBy(PendingRetryRequest request) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, - resource: request.QueueAddress, count: null, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: request.QueueAddress, count: null, operationId: operationId); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await session.SendLocal(m => + await session.Send(m => { m.QueueAddress = request.QueueAddress; m.PeriodFrom = request.From; m.PeriodTo = request.To; - }); + }, sendOptions); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index b3b0e581cf..86d5109305 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using InternalMessages; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -35,10 +36,16 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") { if (string.IsNullOrWhiteSpace(instanceId) || instanceId == settings.InstanceId) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, - resource: failedMessageId, count: 1, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: operationId); - await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await messageSession.Send(m => m.FailedMessageId = failedMessageId, sendOptions); return Accepted(); } @@ -69,16 +76,15 @@ public async Task RetryAllBy(List messageIds) } var user = userAccessor.Resolve(User); - var operationId = Guid.NewGuid().ToString("N"); + var operationId = this.AuditOperationId(); auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, resource: null, count: messageIds.Count, operationId: operationId); - foreach (var id in messageIds) - { - auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, - MessageActionScope.Batch, messageId: id, operationId: operationId); - } - await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds.ToArray()); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await messageSession.Send(m => m.MessageUniqueIds = messageIds.ToArray(), sendOptions); return Accepted(); } @@ -88,14 +94,20 @@ public async Task RetryAllBy(List messageIds) [HttpPost] public async Task RetryAllBy(string queueAddress) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, - resource: queueAddress, count: null, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: queueAddress, count: null, operationId: operationId); - await messageSession.SendLocal(m => + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await messageSession.Send(m => { m.QueueAddress = queueAddress; m.Status = FailedMessageStatus.Unresolved; - }); + }, sendOptions); return Accepted(); } @@ -105,10 +117,16 @@ await messageSession.SendLocal(m => [HttpPost] public async Task RetryAll() { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.All, - resource: null, count: null, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.All, + resource: null, count: null, operationId: operationId); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await messageSession.SendLocal(new RequestRetryAll()); + await messageSession.Send(new RequestRetryAll(), sendOptions); return Accepted(); } @@ -118,10 +136,16 @@ public async Task RetryAll() [HttpPost] public async Task RetryAllByEndpoint(string endpointName) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Endpoint, - resource: endpointName, count: null, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Endpoint, + resource: endpointName, count: null, operationId: operationId); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName }); + await messageSession.Send(new RequestRetryAll { Endpoint = endpointName }, sendOptions); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index a015268aeb..dd756c2ede 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using InternalMessages; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -25,18 +26,15 @@ public async Task Unarchive(string[] ids) } var user = userAccessor.Resolve(User); - var operationId = Guid.NewGuid().ToString("N"); + var operationId = this.AuditOperationId(); auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, resource: null, count: ids.Length, operationId: operationId); - foreach (var id in ids) - { - auditLog.MessageAction(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, - MessageActionScope.Batch, messageId: id, operationId: operationId); - } - var request = new UnArchiveMessages { FailedMessageIds = ids }; + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await session.SendLocal(request); + await session.Send(new UnArchiveMessages { FailedMessageIds = ids }, sendOptions); return Accepted(); } @@ -58,10 +56,16 @@ public async Task Unarchive(string from, string to) return BadRequest(); } - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, - resource: $"{from}...{to}", count: null, operationId: Guid.NewGuid().ToString("N")); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, + resource: $"{from}...{to}", count: null, operationId: operationId); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); - await session.SendLocal(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }); + await session.Send(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }, sendOptions); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs index 6bbdd411d3..2713abb4cf 100644 --- a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs +++ b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs @@ -2,13 +2,14 @@ { using System.Threading.Tasks; using Contracts.MessageFailures; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using InternalMessages; using NServiceBus; using ServiceControl.Persistence; [Handler] - class ArchiveMessageHandler(IErrorMessageDataStore dataStore, IDomainEvents domainEvents) : IHandleMessages + class ArchiveMessageHandler(IErrorMessageDataStore dataStore, IDomainEvents domainEvents, IMessageActionAuditLog auditLog) : IHandleMessages { public async Task Handle(ArchiveMessage message, IMessageHandlerContext context) { @@ -24,6 +25,12 @@ await domainEvents.Raise(new FailedMessageArchived }, context.CancellationToken); await dataStore.FailedMessageMarkAsArchived(failedMessageId); + + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrEmpty(operationId)) + { + auditLog.MessageAction(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, failedMessageId, operationId); + } } } } diff --git a/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesByRangeHandler.cs b/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesByRangeHandler.cs index 83eef58e5c..c7d2b6dbc6 100644 --- a/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesByRangeHandler.cs +++ b/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesByRangeHandler.cs @@ -1,19 +1,31 @@ namespace ServiceControl.MessageFailures.Handlers { + using System.Linq; using System.Threading.Tasks; using Contracts.MessageFailures; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using InternalMessages; using NServiceBus; using Persistence; [Handler] - class UnArchiveMessagesByRangeHandler(IErrorMessageDataStore dataStore, IDomainEvents domainEvents) : IHandleMessages + class UnArchiveMessagesByRangeHandler(IErrorMessageDataStore dataStore, IDomainEvents domainEvents, IMessageActionAuditLog auditLog) : IHandleMessages { public async Task Handle(UnArchiveMessagesByRange message, IMessageHandlerContext context) { var ids = await dataStore.UnArchiveMessagesByRange(message.From, message.To); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrEmpty(operationId)) + { + foreach (var id in ids) + { + // ids are Raven document ids (FailedMessages/{uniqueId}); audit records the bare unique id + auditLog.MessageAction(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, id.Replace("FailedMessages/", ""), operationId); + } + } + await domainEvents.Raise(new FailedMessagesUnArchived { DocumentIds = ids, diff --git a/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesHandler.cs b/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesHandler.cs index b85f1a00e3..894cf17730 100644 --- a/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesHandler.cs +++ b/src/ServiceControl/MessageFailures/Handlers/UnArchiveMessagesHandler.cs @@ -1,20 +1,32 @@ namespace ServiceControl.MessageFailures.Handlers { + using System.Linq; using System.Threading.Tasks; using Contracts.MessageFailures; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using InternalMessages; using NServiceBus; using Persistence; [Handler] - class UnArchiveMessagesHandler(IErrorMessageDataStore store, IDomainEvents domainEvents) + class UnArchiveMessagesHandler(IErrorMessageDataStore store, IDomainEvents domainEvents, IMessageActionAuditLog auditLog) : IHandleMessages { public async Task Handle(UnArchiveMessages messages, IMessageHandlerContext context) { var ids = await store.UnArchiveMessages(messages.FailedMessageIds); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrEmpty(operationId)) + { + foreach (var id in ids) + { + // ids are Raven document ids (FailedMessages/{uniqueId}); audit records the bare unique id + auditLog.MessageAction(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, id.Replace("FailedMessages/", ""), operationId); + } + } + await domainEvents.Raise(new FailedMessagesUnArchived { DocumentIds = ids, diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index 78460da67d..5ca138b40f 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -21,15 +22,21 @@ public class FailureGroupsArchiveController( [HttpPost] public async Task ArchiveGroupErrors(string groupId) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Archive, + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + resource: groupId, count: null, operationId: operationId); if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); - await bus.SendLocal(m => { m.GroupId = groupId; }); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await bus.Send(m => { m.GroupId = groupId; }, sendOptions); } return Accepted(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 7fd85a1051..9cde9774ef 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Recoverability.API using System; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -23,19 +24,25 @@ public async Task ArchiveGroupErrors(string groupId) { var started = DateTime.UtcNow; - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Retry, + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, - resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + resource: groupId, count: null, operationId: operationId); if (!retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { await retryingManager.Wait(groupId, RetryType.FailureGroup, started); - await bus.SendLocal(new RetryAllInGroup + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await bus.Send(new RetryAllInGroup { GroupId = groupId, Started = started - }); + }, sendOptions); } return Accepted(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index c74f82503f..11382db8e8 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -21,15 +22,21 @@ public class FailureGroupsUnarchiveController( [HttpPost] public async Task UnarchiveGroupErrors(string groupId) { - auditLog.Operation(userAccessor.Resolve(User), MessageActionKind.Unarchive, + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: Guid.NewGuid().ToString("N")); + resource: groupId, count: null, operationId: operationId); if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); - await bus.SendLocal(m => { m.GroupId = groupId; }); + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await bus.Send(m => { m.GroupId = groupId; }, sendOptions); } return Accepted(); diff --git a/src/ServiceControl/Recoverability/Archiving/ArchiveAllInGroupHandler.cs b/src/ServiceControl/Recoverability/Archiving/ArchiveAllInGroupHandler.cs index bf70107845..69e0dd1ae2 100644 --- a/src/ServiceControl/Recoverability/Archiving/ArchiveAllInGroupHandler.cs +++ b/src/ServiceControl/Recoverability/Archiving/ArchiveAllInGroupHandler.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Recoverability { using System.Threading.Tasks; + using Infrastructure.Auth; using Microsoft.Extensions.Logging; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -16,7 +17,9 @@ public async Task Handle(ArchiveAllInGroup message, IMessageHandlerContext conte return; } - await archiver.ArchiveAllInGroup(message.GroupId); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + + await archiver.ArchiveAllInGroup(message.GroupId, user, operationId); } } } diff --git a/src/ServiceControl/Recoverability/Archiving/UnArchiveAllInGroupHandler.cs b/src/ServiceControl/Recoverability/Archiving/UnArchiveAllInGroupHandler.cs index c84c486be1..a1c6af97a2 100644 --- a/src/ServiceControl/Recoverability/Archiving/UnArchiveAllInGroupHandler.cs +++ b/src/ServiceControl/Recoverability/Archiving/UnArchiveAllInGroupHandler.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Recoverability { using System.Threading.Tasks; + using Infrastructure.Auth; using Microsoft.Extensions.Logging; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -16,7 +17,9 @@ public async Task Handle(UnarchiveAllInGroup message, IMessageHandlerContext con return; } - await archiver.UnarchiveAllInGroup(message.GroupId); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + + await archiver.UnarchiveAllInGroup(message.GroupId, user, operationId); } } } \ No newline at end of file diff --git a/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs b/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs index 62cde7fe7f..6e1a8649f9 100644 --- a/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs +++ b/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Recoverability { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using MessageFailures.InternalMessages; using NServiceBus; using Persistence; @@ -10,9 +11,10 @@ namespace ServiceControl.Recoverability class PendingRetriesHandler : IHandleMessages, IHandleMessages { - public PendingRetriesHandler(IErrorMessageDataStore dataStore) + public PendingRetriesHandler(IErrorMessageDataStore dataStore, IMessageActionAuditLog auditLog) { this.dataStore = dataStore; + this.auditLog = auditLog; } public async Task Handle(RetryPendingMessages message, IMessageHandlerContext context) @@ -21,10 +23,17 @@ public async Task Handle(RetryPendingMessages message, IMessageHandlerContext co var ids = await dataStore.GetRetryPendingMessages(message.PeriodFrom, message.PeriodTo, message.QueueAddress); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + foreach (var id in ids) { await dataStore.RemoveFailedMessageRetryDocument(id); messageIds.Add(id); + + if (!string.IsNullOrEmpty(operationId)) + { + auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, id, operationId); + } } await context.SendLocal(new RetryMessagesById { MessageUniqueIds = messageIds.ToArray() }); @@ -32,14 +41,22 @@ public async Task Handle(RetryPendingMessages message, IMessageHandlerContext co public async Task Handle(RetryPendingMessagesById message, IMessageHandlerContext context) { + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + foreach (var messageUniqueId in message.MessageUniqueIds) { await dataStore.RemoveFailedMessageRetryDocument(messageUniqueId); + + if (!string.IsNullOrEmpty(operationId)) + { + auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, messageUniqueId, operationId); + } } await context.SendLocal(m => m.MessageUniqueIds = message.MessageUniqueIds); } readonly IErrorMessageDataStore dataStore; + readonly IMessageActionAuditLog auditLog; } } \ No newline at end of file diff --git a/src/ServiceControl/Recoverability/Retrying/Handlers/RetriesHandler.cs b/src/ServiceControl/Recoverability/Retrying/Handlers/RetriesHandler.cs index 4a2e7266c5..09d7ef11c3 100644 --- a/src/ServiceControl/Recoverability/Retrying/Handlers/RetriesHandler.cs +++ b/src/ServiceControl/Recoverability/Retrying/Handlers/RetriesHandler.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Recoverability { using System.Threading.Tasks; using Contracts.MessageFailures; + using Infrastructure.Auth; using MessageFailures.InternalMessages; using NServiceBus; using ServiceControl.Persistence; @@ -29,27 +30,38 @@ public Task Handle(MessageFailed message, IMessageHandlerContext context) public Task Handle(RequestRetryAll message, IMessageHandlerContext context) { + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrWhiteSpace(message.Endpoint)) { - retries.StartRetryForEndpoint(message.Endpoint); + retries.StartRetryForEndpoint(message.Endpoint, user, operationId); } else { - retries.StartRetryForAllMessages(); + retries.StartRetryForAllMessages(user, operationId); } return Task.CompletedTask; } - public Task Handle(RetryMessage message, IMessageHandlerContext context) => retries.StartRetryForSingleMessage(message.FailedMessageId); + public Task Handle(RetryMessage message, IMessageHandlerContext context) + { + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + return retries.StartRetryForSingleMessage(message.FailedMessageId, user, operationId); + } - public Task Handle(RetryMessagesById message, IMessageHandlerContext context) => retries.StartRetryForMessageSelection(message.MessageUniqueIds); + public Task Handle(RetryMessagesById message, IMessageHandlerContext context) + { + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + return retries.StartRetryForMessageSelection(message.MessageUniqueIds, user, operationId); + } public Task Handle(RetryMessagesByQueueAddress message, IMessageHandlerContext context) { var failedQueueAddress = message.QueueAddress; - retries.StartRetryForFailedQueueAddress(failedQueueAddress, message.Status); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + retries.StartRetryForFailedQueueAddress(failedQueueAddress, message.Status, user, operationId); return Task.CompletedTask; } diff --git a/src/ServiceControl/Recoverability/Retrying/Handlers/RetryAllInGroupHandler.cs b/src/ServiceControl/Recoverability/Retrying/Handlers/RetryAllInGroupHandler.cs index 97271902fd..38da31b01f 100644 --- a/src/ServiceControl/Recoverability/Retrying/Handlers/RetryAllInGroupHandler.cs +++ b/src/ServiceControl/Recoverability/Retrying/Handlers/RetryAllInGroupHandler.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Recoverability { using System; using System.Threading.Tasks; + using Infrastructure.Auth; using Microsoft.Extensions.Logging; using NServiceBus; using ServiceControl.Persistence; @@ -37,11 +38,15 @@ public async Task Handle(RetryAllInGroup message, IMessageHandlerContext context var started = message.Started ?? DateTime.UtcNow; await retryingManager.Wait(message.GroupId, RetryType.FailureGroup, started, originator, group?.Type, group?.Last); + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + retries.EnqueueRetryForFailureGroup(new RetriesGateway.RetryForFailureGroup( message.GroupId, originator, group?.Type, - started + started, + user, + operationId )); } } diff --git a/src/ServiceControl/Recoverability/Retrying/RetriesGateway.cs b/src/ServiceControl/Recoverability/Retrying/RetriesGateway.cs index 7c1f8941a7..793310a1c0 100644 --- a/src/ServiceControl/Recoverability/Retrying/RetriesGateway.cs +++ b/src/ServiceControl/Recoverability/Retrying/RetriesGateway.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Recoverability using System.Linq; using System.Threading.Tasks; using Infrastructure; + using Infrastructure.Auth; using MessageFailures; using Microsoft.Extensions.Logging; using ServiceControl.Persistence; @@ -19,7 +20,7 @@ public RetriesGateway(IRetryDocumentDataStore store, RetryingManager operationMa this.logger = logger; } - public async Task StartRetryForSingleMessage(string uniqueMessageId) + public async Task StartRetryForSingleMessage(string uniqueMessageId, AuditUser? initiatedBy = null, string operationId = null) { logger.LogInformation("Retrying a single message {UniqueMessageId}", uniqueMessageId); @@ -28,11 +29,11 @@ public async Task StartRetryForSingleMessage(string uniqueMessageId) var numberOfMessages = 1; await operationManager.Preparing(requestId, retryType, numberOfMessages); - await StageRetryByUniqueMessageIds(requestId, retryType, new[] { uniqueMessageId }, DateTime.UtcNow); + await StageRetryByUniqueMessageIds(requestId, retryType, new[] { uniqueMessageId }, DateTime.UtcNow, initiatedBy: initiatedBy, operationId: operationId); await operationManager.PreparedBatch(requestId, retryType, numberOfMessages); } - public async Task StartRetryForMessageSelection(string[] uniqueMessageIds) + public async Task StartRetryForMessageSelection(string[] uniqueMessageIds, AuditUser? initiatedBy = null, string operationId = null) { logger.LogInformation("Retrying a selection of {MessageCount} messages", uniqueMessageIds.Length); @@ -41,11 +42,11 @@ public async Task StartRetryForMessageSelection(string[] uniqueMessageIds) var numberOfMessages = uniqueMessageIds.Length; await operationManager.Preparing(requestId, retryType, numberOfMessages); - await StageRetryByUniqueMessageIds(requestId, retryType, uniqueMessageIds, DateTime.UtcNow); + await StageRetryByUniqueMessageIds(requestId, retryType, uniqueMessageIds, DateTime.UtcNow, initiatedBy: initiatedBy, operationId: operationId); await operationManager.PreparedBatch(requestId, retryType, numberOfMessages); } - async Task StageRetryByUniqueMessageIds(string requestId, RetryType retryType, string[] messageIds, DateTime startTime, DateTime? last = null, string originator = null, string batchName = null, string classifier = null) + async Task StageRetryByUniqueMessageIds(string requestId, RetryType retryType, string[] messageIds, DateTime startTime, DateTime? last = null, string originator = null, string batchName = null, string classifier = null, AuditUser? initiatedBy = null, string operationId = null) { if (messageIds == null || !messageIds.Any()) { @@ -55,7 +56,7 @@ async Task StageRetryByUniqueMessageIds(string requestId, RetryType retryType, s var failedMessageRetryIds = messageIds.Select(FailedMessageRetry.MakeDocumentId).ToArray(); - var batchDocumentId = await store.CreateBatchDocument(RetryDocumentManager.RetrySessionId, requestId, retryType, failedMessageRetryIds, originator, startTime, last, batchName, classifier); + var batchDocumentId = await store.CreateBatchDocument(RetryDocumentManager.RetrySessionId, requestId, retryType, failedMessageRetryIds, originator, startTime, last, batchName, classifier, initiatedBy?.Id, initiatedBy?.Name, operationId); logger.LogInformation("Created Batch '{BatchDocumentId}' with {BatchMessageCount} messages for '{BatchName}'", batchDocumentId, messageIds.Length, batchName); @@ -94,7 +95,7 @@ async Task ProcessRequest(BulkRetryRequest request) for (var i = 0; i < batches.Count; i++) { - await StageRetryByUniqueMessageIds(request.RequestId, request.RetryType, batches[i], request.StartTime, latestAttempt, request.Originator, GetBatchName(i + 1, batches.Count, request.Originator), request.Classifier); + await StageRetryByUniqueMessageIds(request.RequestId, request.RetryType, batches[i], request.StartTime, latestAttempt, request.Originator, GetBatchName(i + 1, batches.Count, request.Originator), request.Classifier, request.InitiatedBy, request.OperationId); numberOfMessagesAdded += batches[i].Length; await operationManager.PreparedBatch(request.RequestId, request.RetryType, numberOfMessagesAdded); @@ -112,23 +113,23 @@ static string GetBatchName(int pageNum, int totalPages, string context) return $"'{context}' batch {pageNum} of {totalPages}"; } - public void StartRetryForAllMessages() + public void StartRetryForAllMessages(AuditUser? initiatedBy = null, string operationId = null) { - var item = new RetryForAllMessages(); + var item = new RetryForAllMessages(initiatedBy, operationId); logger.LogInformation("Enqueuing index based bulk retry '{Item}'", item); bulkRequests.Enqueue(item); } - public void StartRetryForEndpoint(string endpoint) + public void StartRetryForEndpoint(string endpoint, AuditUser? initiatedBy = null, string operationId = null) { - var item = new RetryForEndpoint(endpoint); + var item = new RetryForEndpoint(endpoint, initiatedBy, operationId); logger.LogInformation("Enqueuing index based bulk retry '{Item}'", item); bulkRequests.Enqueue(item); } - public void StartRetryForFailedQueueAddress(string failedQueueAddress, FailedMessageStatus status) + public void StartRetryForFailedQueueAddress(string failedQueueAddress, FailedMessageStatus status, AuditUser? initiatedBy = null, string operationId = null) { - var item = new RetryForFailedQueueAddress(failedQueueAddress, status); + var item = new RetryForFailedQueueAddress(failedQueueAddress, status, initiatedBy, operationId); logger.LogInformation("Enqueuing index based bulk retry '{Item}'", item); bulkRequests.Enqueue(item); } @@ -153,18 +154,24 @@ public abstract class BulkRetryRequest public string Originator { get; } public string Classifier { get; } public DateTime StartTime { get; } + public AuditUser? InitiatedBy { get; } + public string OperationId { get; } public BulkRetryRequest( string requestId, RetryType retryType, DateTime startTime, - string originator + string originator, + AuditUser? initiatedBy = null, + string operationId = null ) { RequestId = requestId; RetryType = retryType; Originator = originator; StartTime = startTime; + InitiatedBy = initiatedBy; + OperationId = operationId; } protected abstract Task Invoke(IRetryDocumentDataStore store, Func callback); @@ -209,7 +216,7 @@ Task Process(string uniqueMessageId, DateTime latestTimeOfFailure) class RetryForAllMessages : BulkRetryRequest { - public RetryForAllMessages() : base(requestId: "All", RetryType.All, DateTime.UtcNow, "all messages") + public RetryForAllMessages(AuditUser? initiatedBy = null, string operationId = null) : base(requestId: "All", RetryType.All, DateTime.UtcNow, "all messages", initiatedBy, operationId) { } @@ -223,7 +230,7 @@ class RetryForEndpoint : BulkRetryRequest { public string Endpoint { get; } - public RetryForEndpoint(string endpoint) : base(requestId: endpoint, RetryType.AllForEndpoint, DateTime.UtcNow, originator: $"all messages for endpoint {endpoint}") + public RetryForEndpoint(string endpoint, AuditUser? initiatedBy = null, string operationId = null) : base(requestId: endpoint, RetryType.AllForEndpoint, DateTime.UtcNow, originator: $"all messages for endpoint {endpoint}", initiatedBy, operationId) { Endpoint = endpoint; } @@ -240,7 +247,7 @@ public sealed class RetryForFailureGroup : BulkRetryRequest public string GroupTitle { get; } public string GroupType { get; } - public RetryForFailureGroup(string groupId, string groupTitle, string groupType, DateTime started) : base(requestId: groupId, RetryType.FailureGroup, started, originator: groupTitle) + public RetryForFailureGroup(string groupId, string groupTitle, string groupType, DateTime started, AuditUser? initiatedBy = null, string operationId = null) : base(requestId: groupId, RetryType.FailureGroup, started, originator: groupTitle, initiatedBy, operationId) { GroupId = groupId; GroupType = groupType; @@ -267,8 +274,10 @@ class RetryForFailedQueueAddress : BulkRetryRequest public RetryForFailedQueueAddress( string failedQueueAddress, - FailedMessageStatus status - ) : base(requestId: failedQueueAddress, RetryType.ByQueueAddress, DateTime.UtcNow, originator: $"all messages for failed queue address '{failedQueueAddress}'") + FailedMessageStatus status, + AuditUser? initiatedBy = null, + string operationId = null + ) : base(requestId: failedQueueAddress, RetryType.ByQueueAddress, DateTime.UtcNow, originator: $"all messages for failed queue address '{failedQueueAddress}'", initiatedBy, operationId) { FailedQueueAddress = failedQueueAddress; Status = status; diff --git a/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs b/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs index dbdeab6477..d22c433fb9 100644 --- a/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs +++ b/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Recoverability using System.Linq; using System.Threading; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using MessageFailures; using Microsoft.Extensions.Logging; @@ -22,6 +23,7 @@ public RetryProcessor( ReturnToSenderDequeuer returnToSender, RetryingManager retryingManager, Lazy messageDispatcher, + IMessageActionAuditLog auditLog, ILogger logger) { this.store = store; @@ -29,6 +31,7 @@ public RetryProcessor( this.retryingManager = retryingManager; this.domainEvents = domainEvents; this.messageDispatcher = messageDispatcher; + this.auditLog = auditLog; this.logger = logger; corruptedReplyToHeaderStrategy = new CorruptedReplyToHeaderStrategy(RuntimeEnvironment.MachineName, logger); } @@ -215,6 +218,8 @@ async Task Stage(RetryBatch stagingBatch, IRetryBatchesManager manager) await TryDispatch(transportOperations, messages, failedMessageRetriesById, stagingId, previousAttemptFailed); + AuditStagedMessages(stagingBatch, messages); + if (stagingBatch.RetryType != RetryType.FailureGroup) //FailureGroup published on completion of entire group { var failedIds = messages.Select(x => x.UniqueMessageId).ToArray(); @@ -235,6 +240,38 @@ await domainEvents.Raise(new MessagesSubmittedForRetry return messages.Length; } + // Emits one per-message audit entry for each message actually staged for retry. Only bulk/group + // operations (retry all/endpoint/queue/group) carry an OperationId here; the explicit-id and + // single paths are audited synchronously at the API and leave OperationId null so we don't double-log. + void AuditStagedMessages(RetryBatch stagingBatch, IReadOnlyCollection messages) + { + if (string.IsNullOrEmpty(stagingBatch.OperationId)) + { + return; + } + + var user = new AuditUser(stagingBatch.InitiatedById, stagingBatch.InitiatedByName); + var scope = stagingBatch.RetryType switch + { + RetryType.All => MessageActionScope.All, + RetryType.AllForEndpoint => MessageActionScope.Endpoint, + RetryType.ByQueueAddress => MessageActionScope.Queue, + RetryType.FailureGroup => MessageActionScope.Group, + RetryType.MultipleMessages => MessageActionScope.Batch, + RetryType.SingleMessage => MessageActionScope.Single, + RetryType.Unknown => MessageActionScope.Single, + _ => MessageActionScope.Single + }; + var permission = stagingBatch.RetryType == RetryType.FailureGroup + ? Permissions.ErrorRecoverabilityGroupsRetry + : Permissions.ErrorMessagesRetry; + + foreach (var failedMessage in messages) + { + auditLog.MessageAction(user, MessageActionKind.Retry, permission, scope, failedMessage.UniqueMessageId, stagingBatch.OperationId); + } + } + Task TryDispatch(TransportOperation[] transportOperations, IReadOnlyCollection messages, IReadOnlyDictionary failedMessageRetriesById, string stagingId, bool previousAttemptFailed) @@ -332,6 +369,7 @@ TransportOperation ToTransportOperation(FailedMessage message, string stagingId) readonly ReturnToSenderDequeuer returnToSender; readonly RetryingManager retryingManager; readonly Lazy messageDispatcher; + readonly IMessageActionAuditLog auditLog; MessageRedirectsCollection redirects; bool isRecoveringFromPrematureShutdown = true; CorruptedReplyToHeaderStrategy corruptedReplyToHeaderStrategy; diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 685bc7dc16..ddd083db9e 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -1,8 +1,10 @@ namespace ServiceControl; +using System.Threading.Tasks; using Infrastructure.SignalR; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; using ServiceControl.Infrastructure; @@ -11,6 +13,20 @@ public static class WebApplicationExtensions { public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { + // Surface the per-request id (same value used as the audit operation id) so callers can correlate + // and quote it. TraceIdentifier is stable for the request; OnStarting sets it before the response flushes. + app.Use((context, next) => + { + context.Response.OnStarting(static state => + { + var httpContext = (HttpContext)state; + httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; + return Task.CompletedTask; + }, context); + + return next(context); + }); + app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); From 7cbeb5cc6f7bae5eb420eeac059a9e204cb359fb Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 3 Jul 2026 14:20:06 +0200 Subject: [PATCH 15/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Omit=20null-valued?= =?UTF-8?q?=20properties=20from=20audit=20ECS=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set JsonIgnoreCondition.WhenWritingNull on both audit logs so unset fields (resource/count/message) are dropped rather than emitted as null. Idiomatic ECS, and trims the high-volume per-message stream. --- .../Auth/AuthorizationAuditLogTests.cs | 2 +- .../Auth/MessageActionAuditLogTests.cs | 18 ++++++++++++++++++ .../Auth/AuthorizationAuditLog.cs | 3 ++- .../Auth/MessageActionAuditLog.cs | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs index 69213d9887..1db40a1b6c 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs @@ -45,7 +45,7 @@ public void Decision_deny_emits_one_entry_on_audit_category() Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002")); - Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(ecs.GetProperty("servicecontrol").TryGetProperty("resource", out _), Is.False, "null resource should be omitted"); Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning)); } diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs index 936290539e..384b32814f 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs @@ -84,6 +84,24 @@ public void Operation_failure_logs_as_warning() Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); } + [Test] + public void Null_valued_fields_are_omitted() + { + var (provider, log) = Create(); + + log.Operation(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry", + MessageActionScope.All, resource: null, count: null, operationId: "op-5"); + + var sc = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("servicecontrol"); + using (Assert.EnterMultipleScope()) + { + Assert.That(sc.TryGetProperty("resource", out _), Is.False); + Assert.That(sc.TryGetProperty("count", out _), Is.False); + Assert.That(sc.TryGetProperty("message", out _), Is.False); + Assert.That(sc.GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-5")); + } + } + [TestCase(null, "op")] [TestCase("", "op")] [TestCase("error:messages:retry", null)] diff --git a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs index 75a15fb980..38718c0fa4 100644 --- a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Collections.Generic; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; /// @@ -19,7 +20,7 @@ public sealed partial class AuthorizationAuditLog(ILoggerFactory loggerFactory) // Relaxed escaping keeps the JSON readable for log sinks (no \uXXXX for '+', '<', accented names, …); // the HTML-safe default only matters in a browser context, which an audit log is not. - static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) { diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs index eb08740499..862082a3ca 100644 --- a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Collections.Generic; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; /// @@ -19,7 +20,7 @@ public sealed partial class MessageActionAuditLog : IMessageActionAuditLog public const string MessageCategory = AuthorizationAuditLog.AuditCategory + ".Messages"; // "ServiceControl.Audit.Messages" // Relaxed escaping keeps the JSON readable for log sinks, matching AuthorizationAuditLog. - static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; readonly ILogger operationLogger; readonly ILogger messageLogger; From 6b8c062eac04b4f657083b357cfa5f5c58556376 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 3 Jul 2026 15:36:16 +0200 Subject: [PATCH 16/32] =?UTF-8?q?=E2=9A=9C=EF=B8=8F=20Use=20file-scoped=20?= =?UTF-8?q?namespaces=20for=20new=20audit=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArchiveGroupPerMessageAuditTests.cs | 139 +++++++++--------- .../WebApi/AuditOperationIdExtensions.cs | 33 ++--- 2 files changed, 85 insertions(+), 87 deletions(-) diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs index f15ff84d8e..55fcc5f300 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs @@ -1,86 +1,85 @@ -namespace ServiceControl.Persistence.Tests.RavenDB.Archiving -{ - using System; - using System.Linq; - using System.Threading.Tasks; - using Microsoft.Extensions.DependencyInjection; - using NServiceBus.Testing; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.MessageFailures; - using ServiceControl.Persistence.Tests.Recoverability; - using ServiceControl.Recoverability; +namespace ServiceControl.Persistence.Tests.RavenDB.Archiving; - [TestFixture] - class ArchiveGroupPerMessageAuditTests : RavenPersistenceTestBase - { - readonly RecordingMessageActionAuditLog audit = new(); +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.Tests.Recoverability; +using ServiceControl.Recoverability; - public ArchiveGroupPerMessageAuditTests() => - RegisterServices = services => - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(audit); - }; +[TestFixture] +class ArchiveGroupPerMessageAuditTests : RavenPersistenceTestBase +{ + readonly RecordingMessageActionAuditLog audit = new(); - [Test] - public async Task Each_archived_message_is_audited_with_the_initiating_user() + public ArchiveGroupPerMessageAuditTests() => + RegisterServices = services => { - var groupId = "TestGroup"; - var user = new AuditUser("alice-sub", "Alice"); - const string operationId = "op-arch"; + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(audit); + }; - using (var session = DocumentStore.OpenAsyncSession()) - { - foreach (var id in new[] { "A", "B" }) - { - await session.StoreAsync(new FailedMessage - { - Id = "FailedMessages/" + id, - UniqueMessageId = id, - Status = FailedMessageStatus.Unresolved - }); - } + [Test] + public async Task Each_archived_message_is_audited_with_the_initiating_user() + { + var groupId = "TestGroup"; + var user = new AuditUser("alice-sub", "Alice"); + const string operationId = "op-arch"; - await session.StoreAsync(new ArchiveBatch + using (var session = DocumentStore.OpenAsyncSession()) + { + foreach (var id in new[] { "A", "B" }) + { + await session.StoreAsync(new FailedMessage { - Id = ArchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 0), - DocumentIds = ["FailedMessages/A", "FailedMessages/B"] + Id = "FailedMessages/" + id, + UniqueMessageId = id, + Status = FailedMessageStatus.Unresolved }); + } - await session.StoreAsync(new ArchiveOperation - { - Id = ArchiveOperation.MakeId(groupId, ArchiveType.FailureGroup), - RequestId = groupId, - ArchiveType = ArchiveType.FailureGroup, - TotalNumberOfMessages = 2, - NumberOfMessagesArchived = 0, - Started = DateTime.UtcNow, - GroupName = "Test Group", - NumberOfBatches = 1, - CurrentBatch = 0, - InitiatedById = user.Id, - InitiatedByName = user.Name, - OperationId = operationId - }); + await session.StoreAsync(new ArchiveBatch + { + Id = ArchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 0), + DocumentIds = ["FailedMessages/A", "FailedMessages/B"] + }); - await session.SaveChangesAsync(); - } + await session.StoreAsync(new ArchiveOperation + { + Id = ArchiveOperation.MakeId(groupId, ArchiveType.FailureGroup), + RequestId = groupId, + ArchiveType = ArchiveType.FailureGroup, + TotalNumberOfMessages = 2, + NumberOfMessagesArchived = 0, + Started = DateTime.UtcNow, + GroupName = "Test Group", + NumberOfBatches = 1, + CurrentBatch = 0, + InitiatedById = user.Id, + InitiatedByName = user.Name, + OperationId = operationId + }); - var handler = ServiceProvider.GetRequiredService(); - var context = new TestableMessageHandlerContext(); + await session.SaveChangesAsync(); + } - await handler.Handle(new ArchiveAllInGroup { GroupId = groupId }, context); + var handler = ServiceProvider.GetRequiredService(); + var context = new TestableMessageHandlerContext(); - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "A", "B" })); - using (Assert.EnterMultipleScope()) - { - Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(user))); - Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == operationId)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Archive)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Group)); - } + await handler.Handle(new ArchiveAllInGroup { GroupId = groupId }, context); + + Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "A", "B" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(user))); + Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == operationId)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Archive)); + Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Group)); } } } diff --git a/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs index 1b983ff7d2..ff412d28fe 100644 --- a/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs @@ -1,21 +1,20 @@ -namespace ServiceControl.Infrastructure.WebApi -{ - using System; - using Microsoft.AspNetCore.Mvc; +namespace ServiceControl.Infrastructure.WebApi; + +using System; +using Microsoft.AspNetCore.Mvc; - static class AuditOperationIdExtensions +static class AuditOperationIdExtensions +{ + /// + /// The audit operation id that ties the synchronous operation audit entry to the asynchronous + /// per-message entries emitted while the operation is carried out. Reuses ASP.NET Core's + /// per-request TraceIdentifier so the id also equals the RequestId already attached + /// to every other log line of the request. Falls back to a GUID when there is no HttpContext + /// (e.g. unit tests invoking the controller directly). + /// + public static string AuditOperationId(this ControllerBase controller) { - /// - /// The audit operation id that ties the synchronous operation audit entry to the asynchronous - /// per-message entries emitted while the operation is carried out. Reuses ASP.NET Core's - /// per-request TraceIdentifier so the id also equals the RequestId already attached - /// to every other log line of the request. Falls back to a GUID when there is no HttpContext - /// (e.g. unit tests invoking the controller directly). - /// - public static string AuditOperationId(this ControllerBase controller) - { - var traceIdentifier = controller.HttpContext?.TraceIdentifier; - return string.IsNullOrEmpty(traceIdentifier) ? Guid.NewGuid().ToString("N") : traceIdentifier; - } + var traceIdentifier = controller.HttpContext?.TraceIdentifier; + return string.IsNullOrEmpty(traceIdentifier) ? Guid.NewGuid().ToString("N") : traceIdentifier; } } From b869ba18d65568b1ba3197c6d0ba6aacc47ae0a9 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 6 Jul 2026 10:40:44 +0200 Subject: [PATCH 17/32] Audit edit operations --- .../Auth/MessageAction.cs | 3 +- .../Recoverability/EditHandlerAuditTests.cs | 131 ++++++++++++++ .../EditFailedMessagesControllerAuditTests.cs | 169 ++++++++++++++++++ .../Api/EditFailedMessagesController.cs | 18 +- .../Recoverability/Editing/EditHandler.cs | 9 +- 5 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs create mode 100644 src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs diff --git a/src/ServiceControl.Infrastructure/Auth/MessageAction.cs b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs index c620a147af..37deffca8d 100644 --- a/src/ServiceControl.Infrastructure/Auth/MessageAction.cs +++ b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs @@ -6,7 +6,8 @@ public enum MessageActionKind { Retry, Archive, - Unarchive + Unarchive, + Edit } /// How the action selected the messages it acts on. diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs new file mode 100644 index 0000000000..f8cfa5c02b --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs @@ -0,0 +1,131 @@ +namespace ServiceControl.Persistence.Tests.Recoverability +{ + using System; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using Contracts.Operations; + using MessageFailures; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.Testing; + using NServiceBus.Transport; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth; + using ServiceControl.Recoverability; + using ServiceControl.Recoverability.Editing; + + sealed class EditHandlerAuditTests : PersistenceTestBase + { + EditHandler handler; + readonly TestableUnicastDispatcher dispatcher = new(); + readonly ErrorQueueNameCache errorQueueNameCache = new() + { + ResolvedErrorAddress = "errorQueueName" + }; + readonly RecordingMessageActionAuditLog audit = new(); + + public EditHandlerAuditTests() => + RegisterServices = services => services + .AddSingleton(dispatcher) + .AddSingleton(errorQueueNameCache) + .AddSingleton(audit) + .AddTransient(); + + [SetUp] + public void Setup() => handler = ServiceProvider.GetRequiredService(); + + [Test] + public async Task Successful_edit_is_audited_with_the_initiating_user() + { + var user = new AuditUser("alice-sub", "Alice"); + var failedMessage = await CreateAndStoreFailedMessage(); + var message = CreateEditMessage(failedMessage.UniqueMessageId); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(user, "op-edit") }; + await handler.Handle(message, context); + + var entry = audit.Messages.Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(entry.MessageId, Is.EqualTo(failedMessage.UniqueMessageId)); + Assert.That(entry.User, Is.EqualTo(user)); + Assert.That(entry.OperationId, Is.EqualTo("op-edit")); + Assert.That(entry.Kind, Is.EqualTo(MessageActionKind.Edit)); + Assert.That(entry.Scope, Is.EqualTo(MessageActionScope.Single)); + } + } + + [Test] + public async Task Discarded_edit_of_missing_message_is_not_audited() + { + var message = CreateEditMessage("some-id"); + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(new AuditUser("alice-sub", "Alice"), "op-edit") }; + + await handler.Handle(message, context); + + Assert.That(audit.Messages, Is.Empty); + } + + [Test] + public async Task Discarded_edit_of_already_edited_message_is_not_audited() + { + var failedMessageId = Guid.NewGuid().ToString(); + var previousEdit = Guid.NewGuid().ToString(); + + await CreateAndStoreFailedMessage(failedMessageId); + + using (var editFailedMessagesManager = await ErrorMessageDataStore.CreateEditFailedMessageManager()) + { + await editFailedMessagesManager.GetFailedMessage(failedMessageId); + await editFailedMessagesManager.SetCurrentEditingRequestId(previousEdit); + await editFailedMessagesManager.SaveChanges(); + } + + var message = CreateEditMessage(failedMessageId); + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(new AuditUser("alice-sub", "Alice"), "op-edit") }; + await handler.Handle(message, context); + + Assert.That(audit.Messages, Is.Empty); + } + + static System.Collections.Generic.Dictionary StampedHeaders(AuditUser user, string operationId) => new() + { + [AuditHeaders.SubjectId] = user.Id, + [AuditHeaders.SubjectName] = user.Name, + [AuditHeaders.OperationId] = operationId + }; + + static EditAndSend CreateEditMessage(string failedMessageId) => + new() + { + FailedMessageId = failedMessageId, + NewBody = Convert.ToBase64String(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())), + NewHeaders = [] + }; + + async Task CreateAndStoreFailedMessage(string failedMessageId = null) + { + failedMessageId ??= Guid.NewGuid().ToString(); + + var failedMessage = new FailedMessage + { + UniqueMessageId = failedMessageId, + Id = FailedMessageIdGenerator.MakeDocumentId(failedMessageId), + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + MessageId = Guid.NewGuid().ToString(), + FailureDetails = new FailureDetails + { + AddressOfFailingEndpoint = "OriginalEndpointAddress" + } + } + ] + }; + await ErrorMessageDataStore.StoreFailedMessagesForTestsOnly(new[] { failedMessage }); + return failedMessage; + } + } +} diff --git a/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs new file mode 100644 index 0000000000..d09d5c311c --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs @@ -0,0 +1,169 @@ +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CompositeViews.Messages; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.EventLog; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Operations; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; +using ServiceControl.UnitTests.Recoverability; +using ServiceBus.Management.Infrastructure.Settings; + +[TestFixture] +public class EditFailedMessagesControllerAuditTests +{ + static EditFailedMessagesController Create(StubErrorMessageDataStore store, RecordingMessageActionAuditLog audit, bool allowMessageEditing = true) => + new(new Settings { AllowMessageEditing = allowMessageEditing }, store, new TestableMessageSession(), NullLogger.Instance, + new StubCurrentUserAccessor(new AuditUser("alice-sub", "Alice")), audit); + + static EditMessageModel ValidEdit() => new() { MessageBody = "body", MessageHeaders = [] }; + + [Test] + public async Task Edit_emits_single_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } } }; + + await Create(store, audit).Edit("msg-1", ValidEdit()); + + var op = audit.Operations.Single(); + Assert.That(op.Kind, Is.EqualTo(MessageActionKind.Edit)); + Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Single)); + Assert.That(op.Resource, Is.EqualTo("msg-1")); + Assert.That(op.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Edit_disabled_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } } }; + + await Create(store, audit, allowMessageEditing: false).Edit("msg-1", ValidEdit()); + + Assert.That(audit.Operations, Is.Empty); + } + + [Test] + public async Task Edit_already_in_progress_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore + { + ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } }, + EditManager = { CurrentEditingRequestId = "some-other-message-id" } + }; + + await Create(store, audit).Edit("msg-1", ValidEdit()); + + Assert.That(audit.Operations, Is.Empty); + } + + [Test] + public async Task Edit_of_missing_message_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = null }; + + await Create(store, audit).Edit("msg-1", ValidEdit()); + + Assert.That(audit.Operations, Is.Empty); + } + + [Test] + public async Task Edit_with_locked_header_violation_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore + { + ErrorByResult = new FailedMessage + { + ProcessingAttempts = { new FailedMessage.ProcessingAttempt { Headers = { [NServiceBus.Headers.MessageId] = "original" } } } + } + }; + + var edit = new EditMessageModel { MessageBody = "body", MessageHeaders = new Dictionary { [NServiceBus.Headers.MessageId] = "tampered" } }; + + await Create(store, audit).Edit("msg-1", edit); + + Assert.That(audit.Operations, Is.Empty); + } + + [Test] + public async Task Edit_with_missing_body_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } } }; + + await Create(store, audit).Edit("msg-1", new EditMessageModel { MessageBody = "", MessageHeaders = [] }); + + Assert.That(audit.Operations, Is.Empty); + } + + sealed class FakeEditFailedMessagesManager : IEditFailedMessagesManager + { + public string? CurrentEditingRequestId { get; set; } + + public void Dispose() + { + } + + public Task SaveChanges() => Task.CompletedTask; + public Task GetFailedMessage(string failedMessageId) => Task.FromResult(null!); + public Task GetCurrentEditingRequestId(string failedMessageId) => Task.FromResult(CurrentEditingRequestId); + public Task SetCurrentEditingRequestId(string editingMessageId) => Task.CompletedTask; + public Task SetFailedMessageAsResolved() => Task.CompletedTask; + } + + sealed class StubErrorMessageDataStore : IErrorMessageDataStore + { + public FailedMessage? ErrorByResult { get; set; } + public FakeEditFailedMessagesManager EditManager { get; } = new(); + + public Task CreateEditFailedMessageManager() => Task.FromResult(EditManager); + public Task ErrorBy(string failedMessageId) => Task.FromResult(ErrorByResult!); + + public Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) => throw new NotImplementedException(); + public Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) => throw new NotImplementedException(); + public Task FailedMessageMarkAsArchived(string failedMessageId) => throw new NotImplementedException(); + public Task FailedMessagesFetch(Guid[] ids) => throw new NotImplementedException(); + public Task StoreFailedErrorImport(FailedErrorImport failure) => throw new NotImplementedException(); + public Task> GetFailureGroupView(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task> GetFailureGroupsByClassifier(string classifier) => throw new NotImplementedException(); + public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) => throw new NotImplementedException(); + public Task ErrorsHead(string status, string modified, string queueAddress) => throw new NotImplementedException(); + public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo) => throw new NotImplementedException(); + public Task> ErrorsSummary() => throw new NotImplementedException(); + public Task ErrorLastBy(string failedMessageId) => throw new NotImplementedException(); + public Task CreateNotificationsManager() => throw new NotImplementedException(); + public Task EditComment(string groupId, string comment) => throw new NotImplementedException(); + public Task DeleteComment(string groupId) => throw new NotImplementedException(); + public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) => throw new NotImplementedException(); + public Task GetGroupErrorsCount(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task>> GetGroup(string groupId, string status, string modified) => throw new NotImplementedException(); + public Task MarkMessageAsResolved(string failedMessageId) => throw new NotImplementedException(); + public Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, string queueAddress, Func processCallback) => throw new NotImplementedException(); + public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => throw new NotImplementedException(); + public Task UnArchiveMessages(IEnumerable failedMessageIds) => throw new NotImplementedException(); + public Task RevertRetry(string messageUniqueId) => throw new NotImplementedException(); + public Task RemoveFailedMessageRetryDocument(string uniqueMessageId) => throw new NotImplementedException(); + public Task GetRetryPendingMessages(DateTime from, DateTime to, string queueAddress) => throw new NotImplementedException(); + public Task FetchFromFailedMessage(string uniqueMessageId) => throw new NotImplementedException(); + public Task StoreEventLogItem(EventLogItem logItem) => throw new NotImplementedException(); + public Task StoreFailedMessagesForTestsOnly(params FailedMessage[] failedMessages) => throw new NotImplementedException(); + } +} diff --git a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs index d5f5d587f6..745ebc435c 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using Infrastructure.Auth; + using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -20,7 +21,9 @@ public class EditFailedMessagesController( Settings settings, IErrorMessageDataStore store, IMessageSession session, - ILogger logger) + ILogger logger, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : ControllerBase { [Authorize(Policy = Permissions.ErrorMessagesEdit)] @@ -79,14 +82,23 @@ public async Task> Edit(string failedMessageId, return BadRequest(); } + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: operationId); + // Encode the body in base64 so that the new body doesn't have to be escaped var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(edit.MessageBody)); - await session.SendLocal(new EditAndSend + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + await session.Send(new EditAndSend { FailedMessageId = failedMessageId, NewBody = base64String, NewHeaders = edit.MessageHeaders - }); + }, sendOptions); return Accepted(new EditRetryResponse { EditIgnored = false }); } diff --git a/src/ServiceControl/Recoverability/Editing/EditHandler.cs b/src/ServiceControl/Recoverability/Editing/EditHandler.cs index 92b74745a2..ce52d77cac 100644 --- a/src/ServiceControl/Recoverability/Editing/EditHandler.cs +++ b/src/ServiceControl/Recoverability/Editing/EditHandler.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Contracts.MessageFailures; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using MessageFailures; using Microsoft.Extensions.Logging; @@ -15,7 +16,7 @@ using ServiceControl.Persistence.MessageRedirects; [Handler] - class EditHandler(IErrorMessageDataStore store, IMessageRedirectsDataStore redirectsStore, IMessageDispatcher dispatcher, ErrorQueueNameCache errorQueueNameCache, IDomainEvents domainEvents, ILogger logger) + class EditHandler(IErrorMessageDataStore store, IMessageRedirectsDataStore redirectsStore, IMessageDispatcher dispatcher, ErrorQueueNameCache errorQueueNameCache, IDomainEvents domainEvents, IMessageActionAuditLog auditLog, ILogger logger) : IHandleMessages { public async Task Handle(EditAndSend message, IMessageHandlerContext context) @@ -57,6 +58,12 @@ public async Task Handle(EditAndSend message, IMessageHandlerContext context) await session.SaveChanges(); } + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrEmpty(operationId)) + { + auditLog.MessageAction(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, message.FailedMessageId, operationId); + } + var redirects = await redirectsStore.GetOrCreate(); var attempt = failedMessage.ProcessingAttempts.Last(); From c7d5f5074ff270eb5cf60d52a733645d225c5b29 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 6 Jul 2026 11:05:07 +0200 Subject: [PATCH 18/32] Update assertions --- .../LoggingConfiguratorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs index f500c518a6..3e32ba8d39 100644 --- a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs @@ -102,7 +102,7 @@ public void Audit_decisions_render_as_valid_structured_json() Assert.That(deny.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); Assert.That(deny.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure")); Assert.That(deny.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002")); - Assert.That(deny.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null), "absent resource should be JSON null"); + Assert.That(deny.GetProperty("servicecontrol").TryGetProperty("resource", out _), Is.False, "absent resource should be omitted from the JSON entirely, not present as null"); }); } From 92d6f946ad60aa6967e5404d66dac6ac8d2a16e5 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 15:00:53 +0200 Subject: [PATCH 19/32] Remove some unneeded white box tests --- .../Auth/AuditServiceRegistrationTests.cs | 29 -------- .../Auth/AuditUserTests.cs | 17 ----- .../EditFailedMessagesControllerAuditTests.cs | 67 ------------------- 3 files changed, 113 deletions(-) delete mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs delete mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs deleted file mode 100644 index d3acbf8145..0000000000 --- a/src/ServiceControl.Infrastructure.Tests/Auth/AuditServiceRegistrationTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -#nullable enable -namespace ServiceControl.Infrastructure.Tests.Auth; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using NUnit.Framework; -using ServiceControl.Configuration; -using ServiceControl.Hosting.Auth; -using ServiceControl.Infrastructure; -using ServiceControl.Infrastructure.Auth; - -[TestFixture] -public class AuditServiceRegistrationTests -{ - [Test] - public void Registers_message_action_audit_and_user_accessor() - { - var builder = Host.CreateApplicationBuilder(); - builder.Services.AddSingleton(LoggerFactory.Create(_ => { })); - var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), validateConfiguration: false, requireServicePulseSettings: false); - - builder.AddServiceControlAuthorization(settings); - - using var provider = builder.Services.BuildServiceProvider(); - Assert.That(provider.GetService(), Is.TypeOf()); - Assert.That(provider.GetService(), Is.TypeOf()); - } -} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs deleted file mode 100644 index ff188e0f7b..0000000000 --- a/src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -#nullable enable -namespace ServiceControl.Infrastructure.Tests.Auth; - -using NUnit.Framework; -using ServiceControl.Infrastructure.Auth; - -[TestFixture] -public class AuditUserTests -{ - [Test] - public void Anonymous_has_sentinel_id_and_name() - { - Assert.That(AuditUser.Anonymous.Id, Is.EqualTo("anonymous")); - Assert.That(AuditUser.Anonymous.Name, Is.EqualTo("anonymous")); - Assert.That(AuditUser.AnonymousValue, Is.EqualTo("anonymous")); - } -} diff --git a/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs index d09d5c311c..96cbb10995 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs @@ -44,73 +44,6 @@ public async Task Edit_emits_single_operation() Assert.That(op.Count, Is.EqualTo(1)); } - [Test] - public async Task Edit_disabled_is_not_audited() - { - var audit = new RecordingMessageActionAuditLog(); - var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } } }; - - await Create(store, audit, allowMessageEditing: false).Edit("msg-1", ValidEdit()); - - Assert.That(audit.Operations, Is.Empty); - } - - [Test] - public async Task Edit_already_in_progress_is_not_audited() - { - var audit = new RecordingMessageActionAuditLog(); - var store = new StubErrorMessageDataStore - { - ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } }, - EditManager = { CurrentEditingRequestId = "some-other-message-id" } - }; - - await Create(store, audit).Edit("msg-1", ValidEdit()); - - Assert.That(audit.Operations, Is.Empty); - } - - [Test] - public async Task Edit_of_missing_message_is_not_audited() - { - var audit = new RecordingMessageActionAuditLog(); - var store = new StubErrorMessageDataStore { ErrorByResult = null }; - - await Create(store, audit).Edit("msg-1", ValidEdit()); - - Assert.That(audit.Operations, Is.Empty); - } - - [Test] - public async Task Edit_with_locked_header_violation_is_not_audited() - { - var audit = new RecordingMessageActionAuditLog(); - var store = new StubErrorMessageDataStore - { - ErrorByResult = new FailedMessage - { - ProcessingAttempts = { new FailedMessage.ProcessingAttempt { Headers = { [NServiceBus.Headers.MessageId] = "original" } } } - } - }; - - var edit = new EditMessageModel { MessageBody = "body", MessageHeaders = new Dictionary { [NServiceBus.Headers.MessageId] = "tampered" } }; - - await Create(store, audit).Edit("msg-1", edit); - - Assert.That(audit.Operations, Is.Empty); - } - - [Test] - public async Task Edit_with_missing_body_is_not_audited() - { - var audit = new RecordingMessageActionAuditLog(); - var store = new StubErrorMessageDataStore { ErrorByResult = new FailedMessage { ProcessingAttempts = { new FailedMessage.ProcessingAttempt() } } }; - - await Create(store, audit).Edit("msg-1", new EditMessageModel { MessageBody = "", MessageHeaders = [] }); - - Assert.That(audit.Operations, Is.Empty); - } - sealed class FakeEditFailedMessagesManager : IEditFailedMessagesManager { public string? CurrentEditingRequestId { get; set; } From 24bac9069d78d5be414c36d272ed14a7ae601c01 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 15:01:48 +0200 Subject: [PATCH 20/32] Use File-scoped namespaces --- .../Recoverability/EditHandlerAuditTests.cs | 195 +++++++----------- .../LockedHeaderModificationValidatorTests.cs | 117 ++++++----- 2 files changed, 138 insertions(+), 174 deletions(-) diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs index f8cfa5c02b..74f6123fe3 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs @@ -1,131 +1,96 @@ -namespace ServiceControl.Persistence.Tests.Recoverability +namespace ServiceControl.Persistence.Tests.Recoverability; +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Contracts.Operations; +using MessageFailures; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus.Testing; +using NServiceBus.Transport; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Recoverability; +using ServiceControl.Recoverability.Editing; + +sealed class EditHandlerAuditTests : PersistenceTestBase { - using System; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - using Contracts.Operations; - using MessageFailures; - using Microsoft.Extensions.DependencyInjection; - using NServiceBus.Testing; - using NServiceBus.Transport; - using NUnit.Framework; - using ServiceControl.Infrastructure.Auth; - using ServiceControl.Recoverability; - using ServiceControl.Recoverability.Editing; - - sealed class EditHandlerAuditTests : PersistenceTestBase + EditHandler handler; + readonly TestableUnicastDispatcher dispatcher = new(); + readonly ErrorQueueNameCache errorQueueNameCache = new() { - EditHandler handler; - readonly TestableUnicastDispatcher dispatcher = new(); - readonly ErrorQueueNameCache errorQueueNameCache = new() - { - ResolvedErrorAddress = "errorQueueName" - }; - readonly RecordingMessageActionAuditLog audit = new(); - - public EditHandlerAuditTests() => - RegisterServices = services => services - .AddSingleton(dispatcher) - .AddSingleton(errorQueueNameCache) - .AddSingleton(audit) - .AddTransient(); - - [SetUp] - public void Setup() => handler = ServiceProvider.GetRequiredService(); - - [Test] - public async Task Successful_edit_is_audited_with_the_initiating_user() - { - var user = new AuditUser("alice-sub", "Alice"); - var failedMessage = await CreateAndStoreFailedMessage(); - var message = CreateEditMessage(failedMessage.UniqueMessageId); - - var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(user, "op-edit") }; - await handler.Handle(message, context); + ResolvedErrorAddress = "errorQueueName" + }; + readonly RecordingMessageActionAuditLog audit = new(); + + public EditHandlerAuditTests() => + RegisterServices = services => services + .AddSingleton(dispatcher) + .AddSingleton(errorQueueNameCache) + .AddSingleton(audit) + .AddTransient(); + + [SetUp] + public void Setup() => handler = ServiceProvider.GetRequiredService(); + + [Test] + public async Task Successful_edit_is_audited_with_the_initiating_user() + { + var user = new AuditUser("alice-sub", "Alice"); + var failedMessage = await CreateAndStoreFailedMessage(); + var message = CreateEditMessage(failedMessage.UniqueMessageId); - var entry = audit.Messages.Single(); - using (Assert.EnterMultipleScope()) - { - Assert.That(entry.MessageId, Is.EqualTo(failedMessage.UniqueMessageId)); - Assert.That(entry.User, Is.EqualTo(user)); - Assert.That(entry.OperationId, Is.EqualTo("op-edit")); - Assert.That(entry.Kind, Is.EqualTo(MessageActionKind.Edit)); - Assert.That(entry.Scope, Is.EqualTo(MessageActionScope.Single)); - } - } + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(user, "op-edit") }; + await handler.Handle(message, context); - [Test] - public async Task Discarded_edit_of_missing_message_is_not_audited() + var entry = audit.Messages.Single(); + using (Assert.EnterMultipleScope()) { - var message = CreateEditMessage("some-id"); - var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(new AuditUser("alice-sub", "Alice"), "op-edit") }; - - await handler.Handle(message, context); - - Assert.That(audit.Messages, Is.Empty); + Assert.That(entry.MessageId, Is.EqualTo(failedMessage.UniqueMessageId)); + Assert.That(entry.User, Is.EqualTo(user)); + Assert.That(entry.OperationId, Is.EqualTo("op-edit")); + Assert.That(entry.Kind, Is.EqualTo(MessageActionKind.Edit)); + Assert.That(entry.Scope, Is.EqualTo(MessageActionScope.Single)); } + } - [Test] - public async Task Discarded_edit_of_already_edited_message_is_not_audited() - { - var failedMessageId = Guid.NewGuid().ToString(); - var previousEdit = Guid.NewGuid().ToString(); - - await CreateAndStoreFailedMessage(failedMessageId); - - using (var editFailedMessagesManager = await ErrorMessageDataStore.CreateEditFailedMessageManager()) - { - await editFailedMessagesManager.GetFailedMessage(failedMessageId); - await editFailedMessagesManager.SetCurrentEditingRequestId(previousEdit); - await editFailedMessagesManager.SaveChanges(); - } - - var message = CreateEditMessage(failedMessageId); - var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(new AuditUser("alice-sub", "Alice"), "op-edit") }; - await handler.Handle(message, context); - - Assert.That(audit.Messages, Is.Empty); - } + static System.Collections.Generic.Dictionary StampedHeaders(AuditUser user, string operationId) => new() + { + [AuditHeaders.SubjectId] = user.Id, + [AuditHeaders.SubjectName] = user.Name, + [AuditHeaders.OperationId] = operationId + }; - static System.Collections.Generic.Dictionary StampedHeaders(AuditUser user, string operationId) => new() + static EditAndSend CreateEditMessage(string failedMessageId) => + new() { - [AuditHeaders.SubjectId] = user.Id, - [AuditHeaders.SubjectName] = user.Name, - [AuditHeaders.OperationId] = operationId + FailedMessageId = failedMessageId, + NewBody = Convert.ToBase64String(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())), + NewHeaders = [] }; - static EditAndSend CreateEditMessage(string failedMessageId) => - new() - { - FailedMessageId = failedMessageId, - NewBody = Convert.ToBase64String(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())), - NewHeaders = [] - }; + async Task CreateAndStoreFailedMessage(string failedMessageId = null) + { + failedMessageId ??= Guid.NewGuid().ToString(); - async Task CreateAndStoreFailedMessage(string failedMessageId = null) + var failedMessage = new FailedMessage { - failedMessageId ??= Guid.NewGuid().ToString(); - - var failedMessage = new FailedMessage - { - UniqueMessageId = failedMessageId, - Id = FailedMessageIdGenerator.MakeDocumentId(failedMessageId), - Status = FailedMessageStatus.Unresolved, - ProcessingAttempts = - [ - new FailedMessage.ProcessingAttempt + UniqueMessageId = failedMessageId, + Id = FailedMessageIdGenerator.MakeDocumentId(failedMessageId), + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + MessageId = Guid.NewGuid().ToString(), + FailureDetails = new FailureDetails { - MessageId = Guid.NewGuid().ToString(), - FailureDetails = new FailureDetails - { - AddressOfFailingEndpoint = "OriginalEndpointAddress" - } + AddressOfFailingEndpoint = "OriginalEndpointAddress" } - ] - }; - await ErrorMessageDataStore.StoreFailedMessagesForTestsOnly(new[] { failedMessage }); - return failedMessage; - } + } + ] + }; + await ErrorMessageDataStore.StoreFailedMessagesForTestsOnly(new[] { failedMessage }); + return failedMessage; } -} +} \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs b/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs index a0b78e70e5..e3179a6722 100644 --- a/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs @@ -1,68 +1,67 @@ -namespace ServiceControl.UnitTests.Recoverability -{ - using System.Collections.Generic; - using ServiceControl.MessageFailures.Api; - using NUnit.Framework; +namespace ServiceControl.UnitTests.Recoverability; - [TestFixture] - public class LockedHeaderModificationValidatorTests - { - [Test] - public void Headers_not_in_the_locked_list_should_not_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "foo", "asdf" } }; - originalMessageHeaders = new Dictionary { { "foo", "blah" } }; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.False); - } +using System.Collections.Generic; +using ServiceControl.MessageFailures.Api; +using NUnit.Framework; - [Test] - public void No_header_on_the_original_message_should_not_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; - originalMessageHeaders = []; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.False); - } +[TestFixture] +public class LockedHeaderModificationValidatorTests +{ + [Test] + public void Headers_not_in_the_locked_list_should_not_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "foo", "asdf" } }; + originalMessageHeaders = new Dictionary { { "foo", "blah" } }; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.False); + } - [Test] - public void No_header_on_the_new_message_should_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "foo", "bar" } }; - originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); - } + [Test] + public void No_header_on_the_original_message_should_not_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; + originalMessageHeaders = []; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.False); + } - [Test] - public void Header_with_different_value_should_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "bar" } }; - originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); - } + [Test] + public void No_header_on_the_new_message_should_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "foo", "bar" } }; + originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); + } - [Test] - public void Header_with_same_value_but_different_casing_should_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; - originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "ASDF" } }; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); - } + [Test] + public void Header_with_different_value_should_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "bar" } }; + originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); + } - [Test] - public void Header_with_same_key_but_different_casing_should_be_treated_as_a_modification() - { - lockedHeaders = new[] { "NServiceBus.MessageId" }; - editedMessageHeaders = new Dictionary { { "nservicebus.messageid", "asdf" } }; - originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; - Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); - } + [Test] + public void Header_with_same_value_but_different_casing_should_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; + originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "ASDF" } }; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); + } - string[] lockedHeaders; - Dictionary editedMessageHeaders; - Dictionary originalMessageHeaders; + [Test] + public void Header_with_same_key_but_different_casing_should_be_treated_as_a_modification() + { + lockedHeaders = new[] { "NServiceBus.MessageId" }; + editedMessageHeaders = new Dictionary { { "nservicebus.messageid", "asdf" } }; + originalMessageHeaders = new Dictionary { { "NServiceBus.MessageId", "asdf" } }; + Assert.That(LockedHeaderModificationValidator.Check(lockedHeaders, editedMessageHeaders, originalMessageHeaders), Is.True); } + + string[] lockedHeaders; + Dictionary editedMessageHeaders; + Dictionary originalMessageHeaders; } \ No newline at end of file From f0b154782efba14d2207acfd2242769325e1479a Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Mon, 6 Jul 2026 15:21:09 +0200 Subject: [PATCH 21/32] Trying to fix format --- .../Recoverability/EditHandlerAuditTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs index 74f6123fe3..8eb35f0f8b 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs @@ -1,4 +1,5 @@ namespace ServiceControl.Persistence.Tests.Recoverability; + using System; using System.Linq; using System.Text; From dc7258e10e2565dee8d723c962afcb6e0894e1a8 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 15:39:15 +0200 Subject: [PATCH 22/32] =?UTF-8?q?=F0=9F=90=9B=20Register=20IMessageActionA?= =?UTF-8?q?uditLog=20in=20all=20hosts=20that=20consume=20the=20input=20que?= =?UTF-8?q?ue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration lived only in AddServiceControlAuthorization, which is called by RunCommand alone. The --import-failed-errors host consumes the same input queue with all handlers registered, so any pending recoverability command (ArchiveMessage, UnArchiveMessages, EditAndSend, ...) failed handler activation and was moved to the error queue. Move the registration to the primary AddServiceControl so every host that can activate the recoverability handlers has it, and add a startup-mode test asserting all RecoverabilityComponent handlers can be activated from the import host's container (via ActivatorUtilities, mirroring NServiceBus). --- .../StartupModeTests.cs | 40 +++++++++++++++++++ .../Auth/PermissionAuthorizationExtensions.cs | 8 ++-- .../HostApplicationBuilderExtensions.cs | 7 ++++ .../Commands/ImportFailedErrorsCommand.cs | 29 ++++++++------ 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests.RavenDB/StartupModeTests.cs b/src/ServiceControl.AcceptanceTests.RavenDB/StartupModeTests.cs index 6e284f6a56..0260330da6 100644 --- a/src/ServiceControl.AcceptanceTests.RavenDB/StartupModeTests.cs +++ b/src/ServiceControl.AcceptanceTests.RavenDB/StartupModeTests.cs @@ -1,10 +1,13 @@ namespace ServiceControl.AcceptanceTests.RavenDB { using System; + using System.Linq; using System.Runtime.Loader; using System.Threading.Tasks; using Hosting.Commands; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using NServiceBus; using NUnit.Framework; using Particular.ServiceControl.Hosting; using Persistence; @@ -54,5 +57,42 @@ public async Task CanRunMaintenanceMode() [Test] public async Task CanRunImportFailedMessagesMode() => await new ImportFailedErrorsCommand().Execute(new HostArguments([]), settings); + + [Test] + public async Task ImportFailedErrorsHostCanActivateAllMessageHandlers() + { + // The import host consumes the instance's regular input queue, so pending recoverability + // commands (e.g. ArchiveMessage from a bulk archive) can arrive while it runs. Every + // handler of the RecoverabilityComponent the host registers must therefore be activatable. + var handlerTypes = typeof(ImportFailedErrorsCommand).Assembly.GetTypes() + .Where(type => !type.IsAbstract && type.GetInterfaces().Any( + i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandleMessages<>))) + .Where(type => type.Namespace!.StartsWith("ServiceControl.Recoverability") || + type.Namespace.StartsWith("ServiceControl.MessageFailures")) + .OrderBy(type => type.FullName) + .ToArray(); + + Assert.That(handlerTypes, Is.Not.Empty); + + using var host = ImportFailedErrorsCommand.BuildHost(settings); + await host.StartAsync(); + try + { + await using var scope = host.Services.CreateAsyncScope(); + Assert.Multiple(() => + { + foreach (var handlerType in handlerTypes) + { + // NServiceBus activates handlers via ActivatorUtilities, resolving constructor + // dependencies from the container without the handler type being registered. + Assert.That(() => ActivatorUtilities.CreateInstance(scope.ServiceProvider, handlerType), Throws.Nothing, handlerType.FullName); + } + }); + } + finally + { + await host.StopAsync(); + } + } } } \ No newline at end of file diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index f19f16744d..ebc000cf8b 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -50,9 +50,11 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h services.AddSingleton(); services.AddSingleton(); - // Message-action audit trail. Registered unconditionally (independent of OIDC being enabled) so - // the action trail is recorded even without authentication, attributed to AuditUser.Anonymous. - services.AddSingleton(); + // Resolves the acting user for controllers. Registered unconditionally (independent of OIDC + // being enabled) so message actions are attributed even without authentication (AuditUser.Anonymous). + // Note: IMessageActionAuditLog is deliberately NOT registered here — message handlers depend on it, + // so it must be available in every host that consumes the input queue (e.g. --import-failed-errors), + // not only in hosts that serve HTTP. The primary instance registers it in AddServiceControl. services.AddSingleton(); // Backs the my/routes manifest: a singleton table projected from the wired endpoints. Reuses diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index d8a6ab6b81..aefa4c7d42 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -6,6 +6,7 @@ namespace Particular.ServiceControl using global::ServiceControl.CustomChecks; using global::ServiceControl.Hosting; using global::ServiceControl.Infrastructure; + using global::ServiceControl.Infrastructure.Auth; using global::ServiceControl.Infrastructure.BackgroundTasks; using global::ServiceControl.Infrastructure.DomainEvents; using global::ServiceControl.Infrastructure.Metrics; @@ -59,6 +60,12 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S services.Configure(options => options.ShutdownTimeout = settings.ShutdownTimeout); services.AddSingleton(); + // Message-action audit trail. Registered here rather than in AddServiceControlAuthorization + // because message handlers (archive/unarchive/edit/retry) depend on it, so it must exist in + // every host that consumes the input queue — including --import-failed-errors, which never + // wires up authorization. + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(settings); services.AddEnvironmentDataProvider(); diff --git a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs index aba55a935d..0c3fb6e3c9 100644 --- a/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs +++ b/src/ServiceControl/Hosting/Commands/ImportFailedErrorsCommand.cs @@ -19,18 +19,7 @@ class ImportFailedErrorsCommand : AbstractCommand { public override async Task Execute(HostArguments args, Settings settings) { - settings.IngestErrorMessages = false; - settings.RunRetryProcessor = false; - settings.DisableHealthChecks = true; - - var endpointConfiguration = new EndpointConfiguration(settings.InstanceName); - var assemblyScanner = endpointConfiguration.AssemblyScanner(); - assemblyScanner.Disable = true; - - var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.AddServiceControl(settings, endpointConfiguration, new RecoverabilityComponent()); - - using var app = hostBuilder.Build(); + using var app = BuildHost(settings); await app.StartAsync(); var importFailedErrors = app.Services.GetRequiredService(); @@ -51,5 +40,21 @@ public override async Task Execute(HostArguments args, Settings settings) await app.StopAsync(CancellationToken.None); } } + + internal static IHost BuildHost(Settings settings) + { + settings.IngestErrorMessages = false; + settings.RunRetryProcessor = false; + settings.DisableHealthChecks = true; + + var endpointConfiguration = new EndpointConfiguration(settings.InstanceName); + var assemblyScanner = endpointConfiguration.AssemblyScanner(); + assemblyScanner.Disable = true; + + var hostBuilder = Host.CreateApplicationBuilder(); + hostBuilder.AddServiceControl(settings, endpointConfiguration, new RecoverabilityComponent()); + + return hostBuilder.Build(); + } } } \ No newline at end of file From a2321a5dddf297b9caa8e59e12cb7b798a15fc42 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 18:39:28 +0200 Subject: [PATCH 23/32] =?UTF-8?q?=F0=9F=90=9B=20Keep=20audit=20attribution?= =?UTF-8?q?=20on=20archive=20operation=20documents=20across=20batches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToArchiveOperation/ToUnarchiveOperation rebuilt the operation document from the in-memory progress state, which does not carry InitiatedById/ InitiatedByName/OperationId, and MessageArchiver stored that stripped copy over the original after every batch. A restart mid-operation then resumed with no attribution and silently skipped the per-message audit entries of all remaining batches — the exact scenario the persisted fields exist for. The attribution is now passed into the mapping explicitly so no call site can drop it, and tests snapshot the persisted document after the first batch (the state a restart resumes from) for both archive and unarchive. Also fixes an IDE0055 formatting error in EditHandlerAuditTests that broke the ServiceControl.Persistence.Tests.RavenDB build. --- .../Archiving/ArchiveOperationExtensions.cs | 19 +- .../Archiving/MessageArchiver.cs | 4 +- .../ArchiveOperationAttributionTests.cs | 171 ++++++++++++++++++ 3 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveOperationAttributionTests.cs diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperationExtensions.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperationExtensions.cs index 81746978c4..a46fda9c81 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperationExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/ArchiveOperationExtensions.cs @@ -1,10 +1,13 @@ -namespace ServiceControl.Persistence.RavenDB.Recoverability +namespace ServiceControl.Persistence.RavenDB.Recoverability { using ServiceControl.Recoverability; static class ArchiveOperationExtensions { - public static ArchiveOperation ToArchiveOperation(this InMemoryArchive a) + // The in-memory progress state does not carry the audit attribution, so it is passed in + // explicitly — the rebuilt document is stored over the original and must keep attributing + // the operation (per-message audit entries are emitted from it when resuming after a restart). + public static ArchiveOperation ToArchiveOperation(this InMemoryArchive a, string initiatedById, string initiatedByName, string operationId) { return new ArchiveOperation { @@ -16,11 +19,14 @@ public static ArchiveOperation ToArchiveOperation(this InMemoryArchive a) Started = a.Started, TotalNumberOfMessages = a.TotalNumberOfMessages, NumberOfBatches = a.NumberOfBatches, - CurrentBatch = a.CurrentBatch + CurrentBatch = a.CurrentBatch, + InitiatedById = initiatedById, + InitiatedByName = initiatedByName, + OperationId = operationId }; } - public static UnarchiveOperation ToUnarchiveOperation(this InMemoryUnarchive u) + public static UnarchiveOperation ToUnarchiveOperation(this InMemoryUnarchive u, string initiatedById, string initiatedByName, string operationId) { return new UnarchiveOperation { @@ -32,7 +38,10 @@ public static UnarchiveOperation ToUnarchiveOperation(this InMemoryUnarchive u) Started = u.Started, TotalNumberOfMessages = u.TotalNumberOfMessages, NumberOfBatches = u.NumberOfBatches, - CurrentBatch = u.CurrentBatch + CurrentBatch = u.CurrentBatch, + InitiatedById = initiatedById, + InitiatedByName = initiatedByName, + OperationId = operationId }; } } diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs index 56f63c0ced..d0adadba88 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs @@ -89,7 +89,7 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul await archivingManager.BatchArchived(archiveOperation.RequestId, archiveOperation.ArchiveType, nextBatch?.DocumentIds.Count ?? 0); - archiveOperation = archivingManager.GetStatusForArchiveOperation(archiveOperation.RequestId, archiveOperation.ArchiveType).ToArchiveOperation(); + archiveOperation = archivingManager.GetStatusForArchiveOperation(archiveOperation.RequestId, archiveOperation.ArchiveType).ToArchiveOperation(auditUser.Id, auditUser.Name, auditOperationId); await archiveDocumentManager.UpdateArchiveOperation(batchSession, archiveOperation); @@ -189,7 +189,7 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n await unarchivingManager.BatchUnarchived(unarchiveOperation.RequestId, unarchiveOperation.ArchiveType, nextBatch?.DocumentIds.Count ?? 0); - unarchiveOperation = unarchivingManager.GetStatusForUnarchiveOperation(unarchiveOperation.RequestId, unarchiveOperation.ArchiveType).ToUnarchiveOperation(); + unarchiveOperation = unarchivingManager.GetStatusForUnarchiveOperation(unarchiveOperation.RequestId, unarchiveOperation.ArchiveType).ToUnarchiveOperation(auditUser.Id, auditUser.Name, auditOperationId); await unarchiveDocumentManager.UpdateUnarchiveOperation(batchSession, unarchiveOperation); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveOperationAttributionTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveOperationAttributionTests.cs new file mode 100644 index 0000000000..a46cd7a0e7 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveOperationAttributionTests.cs @@ -0,0 +1,171 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Archiving; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +/// +/// The operation documents are re-stored after every batch. A restart mid-operation resumes from +/// that persisted state, so it must keep carrying the audit attribution (initiator + operation id) +/// or the per-message audit entries of all remaining batches are silently lost. +/// +[TestFixture] +class ArchiveOperationAttributionTests : RavenPersistenceTestBase +{ + readonly ProbingDomainEvents events = new(); + + public ArchiveOperationAttributionTests() => + RegisterServices = services => services.AddSingleton(events); + + [Test] + public async Task Archive_operation_keeps_attribution_when_stored_between_batches() + { + const string groupId = "TestGroup"; + + using (var session = DocumentStore.OpenAsyncSession()) + { + foreach (var id in new[] { "A", "B", "C", "D" }) + { + await session.StoreAsync(new FailedMessage + { + Id = "FailedMessages/" + id, + UniqueMessageId = id, + Status = FailedMessageStatus.Unresolved + }); + } + + await session.StoreAsync(new ArchiveBatch + { + Id = ArchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 0), + DocumentIds = ["FailedMessages/A", "FailedMessages/B"] + }); + await session.StoreAsync(new ArchiveBatch + { + Id = ArchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 1), + DocumentIds = ["FailedMessages/C", "FailedMessages/D"] + }); + + await session.StoreAsync(new ArchiveOperation + { + Id = ArchiveOperation.MakeId(groupId, ArchiveType.FailureGroup), + RequestId = groupId, + ArchiveType = ArchiveType.FailureGroup, + TotalNumberOfMessages = 4, + NumberOfMessagesArchived = 0, + Started = DateTime.UtcNow, + GroupName = "Test Group", + NumberOfBatches = 2, + CurrentBatch = 0, + InitiatedById = "alice-sub", + InitiatedByName = "Alice", + OperationId = "op-arch" + }); + + await session.SaveChangesAsync(); + } + + // Snapshot the persisted operation right after the first batch is stored — the state a + // restart would resume from. + ArchiveOperation stored = null; + events.OnRaised = async domainEvent => + { + if (domainEvent is FailedMessageGroupBatchArchived && stored == null) + { + using var session = DocumentStore.OpenAsyncSession(); + stored = await session.LoadAsync(ArchiveOperation.MakeId(groupId, ArchiveType.FailureGroup)); + } + }; + + await ArchiveMessages.ArchiveAllInGroup(groupId); + + Assert.That(stored, Is.Not.Null, "operation document should still exist after the first batch"); + using (Assert.EnterMultipleScope()) + { + Assert.That(stored.InitiatedById, Is.EqualTo("alice-sub")); + Assert.That(stored.InitiatedByName, Is.EqualTo("Alice")); + Assert.That(stored.OperationId, Is.EqualTo("op-arch")); + } + } + + [Test] + public async Task Unarchive_operation_keeps_attribution_when_stored_between_batches() + { + const string groupId = "TestGroup"; + + using (var session = DocumentStore.OpenAsyncSession()) + { + foreach (var id in new[] { "A", "B", "C", "D" }) + { + await session.StoreAsync(new FailedMessage + { + Id = "FailedMessages/" + id, + UniqueMessageId = id, + Status = FailedMessageStatus.Archived + }); + } + + await session.StoreAsync(new UnarchiveBatch + { + Id = UnarchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 0), + DocumentIds = ["FailedMessages/A", "FailedMessages/B"] + }); + await session.StoreAsync(new UnarchiveBatch + { + Id = UnarchiveBatch.MakeId(groupId, ArchiveType.FailureGroup, 1), + DocumentIds = ["FailedMessages/C", "FailedMessages/D"] + }); + + await session.StoreAsync(new UnarchiveOperation + { + Id = UnarchiveOperation.MakeId(groupId, ArchiveType.FailureGroup), + RequestId = groupId, + ArchiveType = ArchiveType.FailureGroup, + TotalNumberOfMessages = 4, + NumberOfMessagesUnarchived = 0, + Started = DateTime.UtcNow, + GroupName = "Test Group", + NumberOfBatches = 2, + CurrentBatch = 0, + InitiatedById = "alice-sub", + InitiatedByName = "Alice", + OperationId = "op-unarch" + }); + + await session.SaveChangesAsync(); + } + + UnarchiveOperation stored = null; + events.OnRaised = async domainEvent => + { + if (domainEvent is FailedMessageGroupBatchUnarchived && stored == null) + { + using var session = DocumentStore.OpenAsyncSession(); + stored = await session.LoadAsync(UnarchiveOperation.MakeId(groupId, ArchiveType.FailureGroup)); + } + }; + + await ArchiveMessages.UnarchiveAllInGroup(groupId); + + Assert.That(stored, Is.Not.Null, "operation document should still exist after the first batch"); + using (Assert.EnterMultipleScope()) + { + Assert.That(stored.InitiatedById, Is.EqualTo("alice-sub")); + Assert.That(stored.InitiatedByName, Is.EqualTo("Alice")); + Assert.That(stored.OperationId, Is.EqualTo("op-unarch")); + } + } + + class ProbingDomainEvents : IDomainEvents + { + public Func OnRaised { get; set; } = _ => Task.CompletedTask; + + public Task Raise(T domainEvent, CancellationToken cancellationToken = default) where T : IDomainEvent + => OnRaised(domainEvent); + } +} From 5a413f5a403bdcaad39a40dd241dffd06fa56841 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 18:46:26 +0200 Subject: [PATCH 24/32] =?UTF-8?q?=F0=9F=90=9B=20Audit=20pending=20retries?= =?UTF-8?q?=20at=20staging=20time=20instead=20of=20intent=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingRetriesHandler emitted per-message retry entries when it resolved the ids and then sent RetryMessagesById without the audit headers. That recorded messages as retried that might never be staged (e.g. claimed by a concurrent batch), and severed the attribution chain so the staged batch carried no OperationId — the execution-time entries every other retry path emits were never produced. The handler no longer audits (and no longer needs the audit log); it re-stamps the audit headers on the follow-up command so the batch carries the attribution and RetryProcessor emits the per-message entries when the messages are actually staged, like all other retry paths. Also corrects the stale comments on RetryBatch.OperationId and AuditStagedMessages claiming single/explicit-id retries leave OperationId null — every path threads it; only legacy unstamped commands are null. --- src/ServiceControl.Persistence/RetryBatch.cs | 7 ++-- .../AsyncRangeAndQueueAuditTests.cs | 37 ++++++++++-------- .../Handlers/PendingRetriesHandler.cs | 38 +++++++++---------- .../Recoverability/Retrying/RetryProcessor.cs | 7 ++-- 4 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/ServiceControl.Persistence/RetryBatch.cs b/src/ServiceControl.Persistence/RetryBatch.cs index b4aa4806f2..45f89dddd9 100644 --- a/src/ServiceControl.Persistence/RetryBatch.cs +++ b/src/ServiceControl.Persistence/RetryBatch.cs @@ -19,9 +19,10 @@ public class RetryBatch public RetryBatchStatus Status { get; set; } public IList FailureRetries { get; set; } = []; - // Audit attribution for the initiating operation. Populated only for operations whose messages - // are resolved asynchronously (retry all/endpoint/queue/group), so the per-message audit entry - // can be emitted at the point the batch is actually staged. Null for paths audited at the API. + // Audit attribution for the initiating operation, threaded from the audit headers stamped on the + // internal retry command. Per-message audit entries are emitted when the batch is staged and are + // correlated to the API's operation entry by OperationId. Null only for legacy in-flight commands + // sent without the headers. public string InitiatedById { get; set; } public string InitiatedByName { get; set; } public string OperationId { get; set; } diff --git a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs index ffc925a526..62e5e8cd88 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs @@ -7,6 +7,7 @@ namespace ServiceControl.UnitTests.MessageFailures; using System.Threading; using System.Threading.Tasks; using CompositeViews.Messages; +using NServiceBus; using NServiceBus.Testing; using NUnit.Framework; using ServiceControl.EventLog; @@ -35,41 +36,47 @@ public class AsyncRangeAndQueueAuditTests [AuditHeaders.OperationId] = operationId }; + // Per-message retry entries are emitted at staging time (RetryProcessor.AuditStagedMessages), + // once the message is really retried. The pending-retries handler only resolves ids, so it must + // not audit them itself (a resolved message may still never be staged) — instead it forwards the + // audit headers on the follow-up RetryMessagesById so the staged batch carries the attribution. + [Test] - public async Task PendingRetries_by_queue_audits_each_resolved_message() + public async Task PendingRetries_by_queue_forwards_attribution_to_the_staged_retry() { - var audit = new RecordingMessageActionAuditLog(); var store = new StubErrorMessageDataStore { RetryPendingMessagesResult = ["m-1", "m-2"] }; - var handler = new PendingRetriesHandler(store, audit); + var handler = new PendingRetriesHandler(store); var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-q") }; await handler.Handle(new RetryPendingMessages { QueueAddress = "q", PeriodFrom = DateTime.UtcNow, PeriodTo = DateTime.UtcNow }, context); - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + var sent = context.SentMessages.Single(m => m.Message is RetryMessagesById); + var headers = sent.Options.GetHeaders(); using (Assert.EnterMultipleScope()) { - Assert.That(audit.Messages, Has.All.Matches(m => m.User.Equals(User))); - Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-q")); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Queue)); + Assert.That(((RetryMessagesById)sent.Message).MessageUniqueIds, Is.EquivalentTo(new[] { "m-1", "m-2" })); + Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo(User.Id)); + Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo(User.Name)); + Assert.That(headers[AuditHeaders.OperationId], Is.EqualTo("op-q")); } } [Test] - public async Task PendingRetries_by_ids_audits_each_message() + public async Task PendingRetries_by_ids_forwards_attribution_to_the_staged_retry() { - var audit = new RecordingMessageActionAuditLog(); - var handler = new PendingRetriesHandler(new StubErrorMessageDataStore(), audit); + var handler = new PendingRetriesHandler(new StubErrorMessageDataStore()); var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders("op-pi") }; await handler.Handle(new RetryPendingMessagesById { MessageUniqueIds = ["m-1", "m-2"] }, context); - Assert.That(audit.Messages.Select(m => m.MessageId), Is.EquivalentTo(new[] { "m-1", "m-2" })); + var sent = context.SentMessages.Single(m => m.Message is RetryMessagesById); + var headers = sent.Options.GetHeaders(); using (Assert.EnterMultipleScope()) { - Assert.That(audit.Messages, Has.All.Matches(m => m.OperationId == "op-pi")); - Assert.That(audit.Messages, Has.All.Matches(m => m.Kind == MessageActionKind.Retry)); - Assert.That(audit.Messages, Has.All.Matches(m => m.Scope == MessageActionScope.Batch)); + Assert.That(((RetryMessagesById)sent.Message).MessageUniqueIds, Is.EquivalentTo(new[] { "m-1", "m-2" })); + Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo(User.Id)); + Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo(User.Name)); + Assert.That(headers[AuditHeaders.OperationId], Is.EqualTo("op-pi")); } } diff --git a/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs b/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs index 6e1a8649f9..6177883caa 100644 --- a/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs +++ b/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs @@ -11,10 +11,9 @@ namespace ServiceControl.Recoverability class PendingRetriesHandler : IHandleMessages, IHandleMessages { - public PendingRetriesHandler(IErrorMessageDataStore dataStore, IMessageActionAuditLog auditLog) + public PendingRetriesHandler(IErrorMessageDataStore dataStore) { this.dataStore = dataStore; - this.auditLog = auditLog; } public async Task Handle(RetryPendingMessages message, IMessageHandlerContext context) @@ -23,40 +22,39 @@ public async Task Handle(RetryPendingMessages message, IMessageHandlerContext co var ids = await dataStore.GetRetryPendingMessages(message.PeriodFrom, message.PeriodTo, message.QueueAddress); - var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); - foreach (var id in ids) { await dataStore.RemoveFailedMessageRetryDocument(id); messageIds.Add(id); - - if (!string.IsNullOrEmpty(operationId)) - { - auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, id, operationId); - } } - await context.SendLocal(new RetryMessagesById { MessageUniqueIds = messageIds.ToArray() }); + await SendRetryMessagesById(context, messageIds.ToArray()); } public async Task Handle(RetryPendingMessagesById message, IMessageHandlerContext context) { - var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); - foreach (var messageUniqueId in message.MessageUniqueIds) { await dataStore.RemoveFailedMessageRetryDocument(messageUniqueId); - - if (!string.IsNullOrEmpty(operationId)) - { - auditLog.MessageAction(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, messageUniqueId, operationId); - } } - await context.SendLocal(m => m.MessageUniqueIds = message.MessageUniqueIds); + await SendRetryMessagesById(context, message.MessageUniqueIds); + } + + // The per-message audit entries are emitted at staging time (RetryProcessor.AuditStagedMessages), + // once a message is really retried — a message resolved here may still never be staged. The audit + // headers are re-stamped on the follow-up command so the staged batch carries the attribution. + static Task SendRetryMessagesById(IMessageHandlerContext context, string[] messageUniqueIds) + { + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + + var sendOptions = new SendOptions(); + sendOptions.RouteToThisEndpoint(); + AuditHeaders.Stamp(sendOptions, user, operationId); + + return context.Send(new RetryMessagesById { MessageUniqueIds = messageUniqueIds }, sendOptions); } readonly IErrorMessageDataStore dataStore; - readonly IMessageActionAuditLog auditLog; } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs b/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs index d22c433fb9..f437339b1e 100644 --- a/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs +++ b/src/ServiceControl/Recoverability/Retrying/RetryProcessor.cs @@ -240,9 +240,10 @@ await domainEvents.Raise(new MessagesSubmittedForRetry return messages.Length; } - // Emits one per-message audit entry for each message actually staged for retry. Only bulk/group - // operations (retry all/endpoint/queue/group) carry an OperationId here; the explicit-id and - // single paths are audited synchronously at the API and leave OperationId null so we don't double-log. + // Emits one per-message audit entry for each message actually staged for retry, for every retry + // type: the API emits the operation-level entry, this emits the per-message entries, correlated by + // OperationId. Skipped for batches without an OperationId (legacy in-flight commands sent without + // the audit headers). void AuditStagedMessages(RetryBatch stagingBatch, IReadOnlyCollection messages) { if (string.IsNullOrEmpty(stagingBatch.OperationId)) From f9fa13d481a3d7948aed5206d4039b47fdd2ecbe Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 18:51:02 +0200 Subject: [PATCH 25/32] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20remote=20inst?= =?UTF-8?q?ance's=20Request-Id=20on=20forwarded=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retry forwarded to a remote instance (instance_id routing) is audited on the remote under the remote's TraceIdentifier, and YARP copies that Request-Id back onto the response — but the middleware's OnStarting callback overwrote it with the local proxy's TraceIdentifier, handing the caller an operation id that no audit entry on any instance matches. The header is now set only when absent. The identical middleware was pasted into all three instances; it now lives once in ServiceControl.Hosting (UseRequestIdHeader) next to the other cross-instance pipeline extensions, and the header name constant replaces the magic string in the three CORS exposed-header lists. --- .../Infrastructure/WebApi/Cors.cs | 3 +- .../WebApplicationExtensions.cs | 18 ++-------- .../RequestId/RequestIdHeader.cs | 35 +++++++++++++++++++ .../WebApplicationExtensions.cs | 20 ++--------- .../Infrastructure/RequestIdHeaderTests.cs | 34 ++++++++++++++++++ .../Infrastructure/WebApi/Cors.cs | 3 +- .../WebApplicationExtensions.cs | 18 ++-------- 7 files changed, 80 insertions(+), 51 deletions(-) create mode 100644 src/ServiceControl.Hosting/RequestId/RequestIdHeader.cs create mode 100644 src/ServiceControl.UnitTests/Infrastructure/RequestIdHeaderTests.cs diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs index d206459ad2..e56240da65 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Audit.Infrastructure.WebApi { using Microsoft.AspNetCore.Cors.Infrastructure; + using ServiceControl.Hosting.RequestId; using ServiceControl.Infrastructure; /// @@ -28,7 +29,7 @@ public static CorsPolicy GetDefaultPolicy(CorsSettings settings) } // Headers exposed to the client in the response (accessible via JavaScript) - builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Request-Id"]); + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", RequestIdHeader.HeaderName]); // Headers allowed in the request from the client builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // HTTP methods allowed for cross-origin requests diff --git a/src/ServiceControl.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs index deab24c365..1a3ec118ff 100644 --- a/src/ServiceControl.Audit/WebApplicationExtensions.cs +++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs @@ -1,31 +1,17 @@ namespace ServiceControl.Audit; -using System.Threading.Tasks; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; +using ServiceControl.Hosting.RequestId; using ServiceControl.Infrastructure; public static class WebApplicationExtensions { public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { - // Surface the per-request id so callers can correlate and quote it. TraceIdentifier is stable - // for the request; OnStarting sets it before the response flushes. - app.Use((context, next) => - { - context.Response.OnStarting(static state => - { - var httpContext = (HttpContext)state; - httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; - return Task.CompletedTask; - }, context); - - return next(context); - }); - + app.UseRequestIdHeader(); app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); diff --git a/src/ServiceControl.Hosting/RequestId/RequestIdHeader.cs b/src/ServiceControl.Hosting/RequestId/RequestIdHeader.cs new file mode 100644 index 0000000000..a3b8e8f61f --- /dev/null +++ b/src/ServiceControl.Hosting/RequestId/RequestIdHeader.cs @@ -0,0 +1,35 @@ +#nullable enable +namespace ServiceControl.Hosting.RequestId; + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +/// +/// Surfaces the per-request id (the same value used as the audit operation id) on every response so +/// callers can correlate and quote it. is stable for the +/// request; OnStarting applies it just before the response flushes. +/// +public static class RequestIdHeader +{ + public const string HeaderName = "Request-Id"; + + public static void UseRequestIdHeader(this WebApplication app) => + app.Use((context, next) => + { + context.Response.OnStarting(static state => + { + Apply((HttpContext)state); + return Task.CompletedTask; + }, context); + + return next(context); + }); + + // Set-if-absent: a response proxied from a remote instance already carries the remote's + // Request-Id — the id its audit entries are correlated by — and that is the id the caller + // must receive. + public static void Apply(HttpContext httpContext) => + httpContext.Response.Headers.TryAdd(HeaderName, httpContext.TraceIdentifier); +} diff --git a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs index 58d7a7c58b..1d840240bc 100644 --- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs +++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs @@ -1,30 +1,16 @@ namespace ServiceControl.Monitoring.Infrastructure; -using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; +using ServiceControl.Hosting.RequestId; using ServiceControl.Infrastructure; public static class WebApplicationExtensions { public static void UseServiceControlMonitoring(this WebApplication appBuilder, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings, CorsSettings corsSettings) { - // Surface the per-request id so callers can correlate and quote it. TraceIdentifier is stable - // for the request; OnStarting sets it before the response flushes. - appBuilder.Use((context, next) => - { - context.Response.OnStarting(static state => - { - var httpContext = (HttpContext)state; - httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; - return Task.CompletedTask; - }, context); - - return next(context); - }); - + appBuilder.UseRequestIdHeader(); appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings); appBuilder.UseServiceControlHttps(httpsSettings); @@ -46,7 +32,7 @@ public static void UseServiceControlMonitoring(this WebApplication appBuilder, F } // Headers exposed to the client in the response (accessible via JavaScript) - policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Request-Id"]); + policyBuilder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", RequestIdHeader.HeaderName]); // Headers allowed in the request from the client policyBuilder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // HTTP methods allowed for cross-origin requests diff --git a/src/ServiceControl.UnitTests/Infrastructure/RequestIdHeaderTests.cs b/src/ServiceControl.UnitTests/Infrastructure/RequestIdHeaderTests.cs new file mode 100644 index 0000000000..ad887bbb6f --- /dev/null +++ b/src/ServiceControl.UnitTests/Infrastructure/RequestIdHeaderTests.cs @@ -0,0 +1,34 @@ +namespace ServiceControl.UnitTests.Infrastructure; + +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using ServiceControl.Hosting.RequestId; + +[TestFixture] +public class RequestIdHeaderTests +{ + [Test] + public void Sets_the_request_trace_identifier() + { + var context = new DefaultHttpContext { TraceIdentifier = "local-trace" }; + + RequestIdHeader.Apply(context); + + Assert.That(context.Response.Headers[RequestIdHeader.HeaderName].ToString(), Is.EqualTo("local-trace")); + } + + [Test] + public void Keeps_a_request_id_proxied_from_a_remote_instance() + { + // A request forwarded to a remote instance (instance_id routing) is audited on the remote + // under the remote's TraceIdentifier, which YARP copies back onto this response. That id is + // the one the caller must see — overwriting it with the local proxy's TraceIdentifier would + // hand out an operation id no audit entry matches. + var context = new DefaultHttpContext { TraceIdentifier = "local-trace" }; + context.Response.Headers[RequestIdHeader.HeaderName] = "remote-trace"; + + RequestIdHeader.Apply(context); + + Assert.That(context.Response.Headers[RequestIdHeader.HeaderName].ToString(), Is.EqualTo("remote-trace")); + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Cors.cs b/src/ServiceControl/Infrastructure/WebApi/Cors.cs index ce2c9929db..4321e8eafc 100644 --- a/src/ServiceControl/Infrastructure/WebApi/Cors.cs +++ b/src/ServiceControl/Infrastructure/WebApi/Cors.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Infrastructure.WebApi { using Microsoft.AspNetCore.Cors.Infrastructure; + using ServiceControl.Hosting.RequestId; /// /// Provides CORS (Cross-Origin Resource Sharing) policy configuration for the ServiceControl API. @@ -25,7 +26,7 @@ public static CorsPolicy GetDefaultPolicy(CorsSettings settings) } // Expose custom headers that clients need to read from responses - builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition", "Request-Id"]); + builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Content-Disposition", RequestIdHeader.HeaderName]); // Allow standard headers required for API requests builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]); // Allow all HTTP methods used by the ServiceControl API diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index ddd083db9e..b6cbc520c8 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -1,32 +1,18 @@ namespace ServiceControl; -using System.Threading.Tasks; using Infrastructure.SignalR; using Infrastructure.WebApi; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using ServiceControl.Hosting.ForwardedHeaders; using ServiceControl.Hosting.Https; +using ServiceControl.Hosting.RequestId; using ServiceControl.Infrastructure; public static class WebApplicationExtensions { public static void UseServiceControl(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings) { - // Surface the per-request id (same value used as the audit operation id) so callers can correlate - // and quote it. TraceIdentifier is stable for the request; OnStarting sets it before the response flushes. - app.Use((context, next) => - { - context.Response.OnStarting(static state => - { - var httpContext = (HttpContext)state; - httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier; - return Task.CompletedTask; - }, context); - - return next(context); - }); - + app.UseRequestIdHeader(); app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); From d74676b7407098ef2582cc273611fcf6750ff936 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 18:55:58 +0200 Subject: [PATCH 26/32] =?UTF-8?q?=F0=9F=90=9B=20Don't=20audit=20group=20op?= =?UTF-8?q?erations=20that=20are=20skipped=20as=20already=20in=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group archive/unarchive/retry controllers emitted the success operation entry before the IsOperationInProgressFor guard, so a request ignored as a no-op (operation already running, double-click) was still recorded as a successful operation attributed to the caller — with an operation id that never gains any per-message entries. The audit entry is now emitted inside the guard, next to the work it describes. --- ...FailureGroupsArchiveUnarchiveAuditTests.cs | 26 +++++++++++++++++++ .../FailureGroupsRetryControllerAuditTests.cs | 17 ++++++++++++ .../Recoverability/NoopArchiveMessages.cs | 4 ++- .../API/FailureGroupsArchiveController.cs | 12 ++++----- .../API/FailureGroupsRetryController.cs | 12 ++++----- .../API/FailureGroupsUnarchiveController.cs | 12 ++++----- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs index c9601cab8b..40ff070a7d 100644 --- a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs @@ -40,4 +40,30 @@ public async Task Unarchive_group_emits_operation_entry() Assert.That(op.Scope, Is.EqualTo(MessageActionScope.Group)); Assert.That(op.Resource, Is.EqualTo("group-8")); } + + [Test] + public async Task Archive_group_skipped_as_already_in_progress_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var session = new TestableMessageSession(); + var controller = new FailureGroupsArchiveController(session, new NoopArchiveMessages { OperationInProgress = true }, new StubCurrentUserAccessor(User), audit); + + await controller.ArchiveGroupErrors("group-7"); + + Assert.That(session.SentMessages, Is.Empty); + Assert.That(audit.Operations, Is.Empty, "an ignored request must not be recorded as a successful operation"); + } + + [Test] + public async Task Unarchive_group_skipped_as_already_in_progress_is_not_audited() + { + var audit = new RecordingMessageActionAuditLog(); + var session = new TestableMessageSession(); + var controller = new FailureGroupsUnarchiveController(session, new NoopArchiveMessages { OperationInProgress = true }, new StubCurrentUserAccessor(User), audit); + + await controller.UnarchiveGroupErrors("group-8"); + + Assert.That(session.SentMessages, Is.Empty); + Assert.That(audit.Operations, Is.Empty, "an ignored request must not be recorded as a successful operation"); + } } diff --git a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs index c1c26dda00..e332202078 100644 --- a/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs @@ -1,12 +1,14 @@ #nullable enable namespace ServiceControl.UnitTests.Recoverability; +using System; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using NServiceBus.Testing; using NUnit.Framework; using ServiceControl.Infrastructure.Auth; +using ServiceControl.Persistence; using ServiceControl.Recoverability; using ServiceControl.Recoverability.API; using ServiceControl.UnitTests.Operations; @@ -33,4 +35,19 @@ public async Task Emits_group_retry_operation_entry() Assert.That(op.Resource, Is.EqualTo("group-42")); Assert.That(op.Permission, Is.EqualTo(Permissions.ErrorRecoverabilityGroupsRetry)); } + + [Test] + public async Task Group_retry_skipped_as_already_in_progress_is_not_audited() + { + var session = new TestableMessageSession(); + var audit = new RecordingMessageActionAuditLog(); + var retryingManager = new RetryingManager(new FakeDomainEvents(), NullLogger.Instance); + await retryingManager.Preparing("group-42", RetryType.FailureGroup, totalNumberOfMessages: 10); + var controller = new FailureGroupsRetryController(session, retryingManager, new StubCurrentUserAccessor(new AuditUser("alice-sub", "Alice")), audit); + + await controller.ArchiveGroupErrors("group-42"); + + Assert.That(session.SentMessages, Is.Empty); + Assert.That(audit.Operations, Is.Empty, "an ignored request must not be recorded as a successful operation"); + } } diff --git a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs index fe19844613..b5a8a70bfe 100644 --- a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs +++ b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs @@ -9,11 +9,13 @@ namespace ServiceControl.UnitTests.Recoverability; sealed class NoopArchiveMessages : IArchiveMessages { + public bool OperationInProgress { get; init; } + public Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => Task.CompletedTask; public Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => Task.CompletedTask; - public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => false; + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => OperationInProgress; public bool IsArchiveInProgressFor(string groupId) => false; diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index 5ca138b40f..f4998beb28 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -22,14 +22,14 @@ public class FailureGroupsArchiveController( [HttpPost] public async Task ArchiveGroupErrors(string groupId) { - var user = userAccessor.Resolve(User); - var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Archive, - Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Archive, + Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, + resource: groupId, count: null, operationId: operationId); + await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); var sendOptions = new SendOptions(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 9cde9774ef..6d86ecbcb2 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -24,14 +24,14 @@ public async Task ArchiveGroupErrors(string groupId) { var started = DateTime.UtcNow; - var user = userAccessor.Resolve(User); - var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, - Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - if (!retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Retry, + Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, + resource: groupId, count: null, operationId: operationId); + await retryingManager.Wait(groupId, RetryType.FailureGroup, started); var sendOptions = new SendOptions(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index 11382db8e8..c35dd88789 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -22,14 +22,14 @@ public class FailureGroupsUnarchiveController( [HttpPost] public async Task UnarchiveGroupErrors(string groupId) { - var user = userAccessor.Resolve(User); - var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Unarchive, - Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + auditLog.Operation(user, MessageActionKind.Unarchive, + Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, + resource: groupId, count: null, operationId: operationId); + await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); var sendOptions = new SendOptions(); From 2350e6fea7145ecad2ba54cd03c468eff38afccd Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 19:02:19 +0200 Subject: [PATCH 27/32] =?UTF-8?q?=F0=9F=90=9B=20Record=20the=20real=20outc?= =?UTF-8?q?ome=20on=20operation=20audit=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every controller wrote the operation entry with the implicit success outcome before performing the send, so a transport failure returned HTTP 500 with nothing enqueued while the trail already claimed success — and the failure outcome was dead code no production path could ever emit. Controllers now run the action through AuditedOperation, which executes the send and records the entry afterwards with the actual outcome (failure + rethrow when the send throws). The stamped local SendOptions ritual repeated at every call site is collapsed into AuditHeaders.LocalSendOptions. --- .../Auth/AuditHeaders.cs | 12 ++++ .../Auth/MessageActionAuditLogExtensions.cs | 34 +++++++++ .../OperationOutcomeAuditTests.cs | 70 +++++++++++++++++++ .../Api/ArchiveMessagesController.cs | 30 +++----- .../Api/EditFailedMessagesController.cs | 19 +++-- .../Api/PendingRetryMessagesController.cs | 32 +++------ .../Api/RetryMessagesController.cs | 63 +++++------------ .../Api/UnArchiveMessagesController.cs | 22 ++---- .../API/FailureGroupsArchiveController.cs | 16 ++--- .../API/FailureGroupsRetryController.cs | 24 +++---- .../API/FailureGroupsUnarchiveController.cs | 16 ++--- 11 files changed, 193 insertions(+), 145 deletions(-) create mode 100644 src/ServiceControl.Infrastructure/Auth/MessageActionAuditLogExtensions.cs create mode 100644 src/ServiceControl.UnitTests/MessageFailures/OperationOutcomeAuditTests.cs diff --git a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs index 1469f950d9..0ad71f9604 100644 --- a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs +++ b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs @@ -26,6 +26,18 @@ public static void Stamp(SendOptions options, AuditUser user, string operationId } } + /// + /// Options for sending an internal command to this instance's own queue, stamped with the + /// initiating principal — the ritual every audited API action performs before sending. + /// + public static SendOptions LocalSendOptions(AuditUser user, string operationId) + { + var options = new SendOptions(); + options.RouteToThisEndpoint(); + Stamp(options, user, operationId); + return options; + } + public static (AuditUser User, string? OperationId) Read(IReadOnlyDictionary headers) { headers.TryGetValue(OperationId, out var operationId); diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLogExtensions.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLogExtensions.cs new file mode 100644 index 0000000000..90990bafc0 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLogExtensions.cs @@ -0,0 +1,34 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Threading.Tasks; + +public static class MessageActionAuditLogExtensions +{ + /// + /// Executes a message action and records the operation-level audit entry with the actual + /// outcome: success when the action completed, failure when it threw (the exception is + /// rethrown). Logging after the action keeps the trail truthful — an entry written before the + /// send would claim success for an operation the transport may have rejected. + /// + public static async Task AuditedOperation(this IMessageActionAuditLog auditLog, AuditUser user, + MessageActionKind kind, string permission, MessageActionScope scope, string? resource, + int? count, string operationId, Func action) + { + var success = true; + try + { + await action().ConfigureAwait(false); + } + catch + { + success = false; + throw; + } + finally + { + auditLog.Operation(user, kind, permission, scope, resource, count, operationId, success); + } + } +} diff --git a/src/ServiceControl.UnitTests/MessageFailures/OperationOutcomeAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/OperationOutcomeAuditTests.cs new file mode 100644 index 0000000000..1b188243e9 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/OperationOutcomeAuditTests.cs @@ -0,0 +1,70 @@ +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NServiceBus; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Recoverability.API; +using ServiceControl.UnitTests.Recoverability; + +/// +/// The operation-level audit entry must record what actually happened: an action whose send throws +/// (broker down) returns a 500 to the caller and nothing was enqueued, so the trail must say +/// failure, not success. +/// +[TestFixture] +public class OperationOutcomeAuditTests +{ + static readonly AuditUser User = new("alice-sub", "Alice"); + + [Test] + public void Batch_archive_that_fails_to_send_is_recorded_as_failure() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new ThrowingMessageSession(), null!, new StubCurrentUserAccessor(User), audit); + + Assert.ThrowsAsync(() => controller.ArchiveBatch(["m-1", "m-2"])); + + var op = audit.Operations.Single(); + Assert.That(op.Success, Is.False, "an operation whose send failed must be recorded with a failure outcome"); + } + + [Test] + public void Single_archive_that_fails_to_send_is_recorded_as_failure() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new ArchiveMessagesController(new ThrowingMessageSession(), null!, new StubCurrentUserAccessor(User), audit); + + Assert.ThrowsAsync(() => controller.Archive("m-1")); + + var op = audit.Operations.Single(); + Assert.That(op.Success, Is.False, "an operation whose send failed must be recorded with a failure outcome"); + } + + [Test] + public void Group_archive_that_fails_to_send_is_recorded_as_failure() + { + var audit = new RecordingMessageActionAuditLog(); + var controller = new FailureGroupsArchiveController(new ThrowingMessageSession(), new NoopArchiveMessages(), new StubCurrentUserAccessor(User), audit); + + Assert.ThrowsAsync(() => controller.ArchiveGroupErrors("group-1")); + + var op = audit.Operations.Single(); + Assert.That(op.Success, Is.False, "an operation whose send failed must be recorded with a failure outcome"); + } + + sealed class ThrowingMessageSession : TestableMessageSession + { + public override Task Send(object message, SendOptions options, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("simulated transport failure"); + + public override Task Send(Action messageConstructor, SendOptions options, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("simulated transport failure"); + } +} diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index c1f732f2fa..8bcba65ca9 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -30,17 +30,14 @@ public async Task ArchiveBatch(string[] messageIds) var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, - resource: null, count: messageIds.Length, operationId: operationId); - - foreach (var id in messageIds) - { - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(new ArchiveMessage { FailedMessageId = id }, sendOptions); - } + await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Batch, + resource: null, count: messageIds.Length, operationId: operationId, async () => + { + foreach (var id in messageIds) + { + await messageSession.Send(new ArchiveMessage { FailedMessageId = id }, AuditHeaders.LocalSendOptions(user, operationId)); + } + }); return Accepted(); } @@ -65,14 +62,9 @@ public async Task Archive(string messageId) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, - resource: messageId, count: 1, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(m => m.FailedMessageId = messageId, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, + resource: messageId, count: 1, operationId: operationId, + () => messageSession.Send(m => m.FailedMessageId = messageId, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs index 745ebc435c..20acc62019 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -84,21 +84,18 @@ public async Task> Edit(string failedMessageId, var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, - resource: failedMessageId, count: 1, operationId: operationId); // Encode the body in base64 so that the new body doesn't have to be escaped var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(edit.MessageBody)); - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - await session.Send(new EditAndSend - { - FailedMessageId = failedMessageId, - NewBody = base64String, - NewHeaders = edit.MessageHeaders - }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: operationId, + () => session.Send(new EditAndSend + { + FailedMessageId = failedMessageId, + NewBody = base64String, + NewHeaders = edit.MessageHeaders + }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(new EditRetryResponse { EditIgnored = false }); } diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 4c5db0c9fd..e0b62e3576 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -29,14 +29,9 @@ public async Task RetryBy(string[] ids) var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, - resource: null, count: ids.Length, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await session.Send(m => m.MessageUniqueIds = ids, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: operationId, + () => session.Send(m => m.MessageUniqueIds = ids, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -48,19 +43,14 @@ public async Task RetryBy(PendingRetryRequest request) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, - resource: request.QueueAddress, count: null, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await session.Send(m => - { - m.QueueAddress = request.QueueAddress; - m.PeriodFrom = request.From; - m.PeriodTo = request.To; - }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: request.QueueAddress, count: null, operationId: operationId, + () => session.Send(m => + { + m.QueueAddress = request.QueueAddress; + m.PeriodFrom = request.From; + m.PeriodTo = request.To; + }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index 86d5109305..f09c72e280 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -38,14 +38,9 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, - resource: failedMessageId, count: 1, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(m => m.FailedMessageId = failedMessageId, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Single, + resource: failedMessageId, count: 1, operationId: operationId, + () => messageSession.Send(m => m.FailedMessageId = failedMessageId, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -77,14 +72,9 @@ public async Task RetryAllBy(List messageIds) var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, - resource: null, count: messageIds.Count, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(m => m.MessageUniqueIds = messageIds.ToArray(), sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Batch, + resource: null, count: messageIds.Count, operationId: operationId, + () => messageSession.Send(m => m.MessageUniqueIds = messageIds.ToArray(), AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -96,18 +86,13 @@ public async Task RetryAllBy(string queueAddress) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, - resource: queueAddress, count: null, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(m => - { - m.QueueAddress = queueAddress; - m.Status = FailedMessageStatus.Unresolved; - }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Queue, + resource: queueAddress, count: null, operationId: operationId, + () => messageSession.Send(m => + { + m.QueueAddress = queueAddress; + m.Status = FailedMessageStatus.Unresolved; + }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -119,14 +104,9 @@ public async Task RetryAll() { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.All, - resource: null, count: null, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(new RequestRetryAll(), sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.All, + resource: null, count: null, operationId: operationId, + () => messageSession.Send(new RequestRetryAll(), AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -138,14 +118,9 @@ public async Task RetryAllByEndpoint(string endpointName) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Endpoint, - resource: endpointName, count: null, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await messageSession.Send(new RequestRetryAll { Endpoint = endpointName }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorMessagesRetry, MessageActionScope.Endpoint, + resource: endpointName, count: null, operationId: operationId, + () => messageSession.Send(new RequestRetryAll { Endpoint = endpointName }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index dd756c2ede..bcd37d2040 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -27,14 +27,9 @@ public async Task Unarchive(string[] ids) var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, - resource: null, count: ids.Length, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await session.Send(new UnArchiveMessages { FailedMessageIds = ids }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Batch, + resource: null, count: ids.Length, operationId: operationId, + () => session.Send(new UnArchiveMessages { FailedMessageIds = ids }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } @@ -58,14 +53,9 @@ public async Task Unarchive(string from, string to) var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, - resource: $"{from}...{to}", count: null, operationId: operationId); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await session.Send(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }, sendOptions); + await auditLog.AuditedOperation(user, MessageActionKind.Unarchive, Permissions.ErrorMessagesUnarchive, MessageActionScope.Range, + resource: $"{from}...{to}", count: null, operationId: operationId, + () => session.Send(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index f4998beb28..ec152c57bf 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -26,17 +26,13 @@ public async Task ArchiveGroupErrors(string groupId) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Archive, + await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - - await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await bus.Send(m => { m.GroupId = groupId; }, sendOptions); + resource: groupId, count: null, operationId: operationId, async () => + { + await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); + await bus.Send(m => { m.GroupId = groupId; }, AuditHeaders.LocalSendOptions(user, operationId)); + }); } return Accepted(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 6d86ecbcb2..423237a31e 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -28,21 +28,17 @@ public async Task ArchiveGroupErrors(string groupId) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Retry, + await auditLog.AuditedOperation(user, MessageActionKind.Retry, Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - - await retryingManager.Wait(groupId, RetryType.FailureGroup, started); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await bus.Send(new RetryAllInGroup - { - GroupId = groupId, - Started = started - }, sendOptions); + resource: groupId, count: null, operationId: operationId, async () => + { + await retryingManager.Wait(groupId, RetryType.FailureGroup, started); + await bus.Send(new RetryAllInGroup + { + GroupId = groupId, + Started = started + }, AuditHeaders.LocalSendOptions(user, operationId)); + }); } return Accepted(); diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index c35dd88789..3671a35bc2 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -26,17 +26,13 @@ public async Task UnarchiveGroupErrors(string groupId) { var user = userAccessor.Resolve(User); var operationId = this.AuditOperationId(); - auditLog.Operation(user, MessageActionKind.Unarchive, + await auditLog.AuditedOperation(user, MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, - resource: groupId, count: null, operationId: operationId); - - await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); - - var sendOptions = new SendOptions(); - sendOptions.RouteToThisEndpoint(); - AuditHeaders.Stamp(sendOptions, user, operationId); - - await bus.Send(m => { m.GroupId = groupId; }, sendOptions); + resource: groupId, count: null, operationId: operationId, async () => + { + await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); + await bus.Send(m => { m.GroupId = groupId; }, AuditHeaders.LocalSendOptions(user, operationId)); + }); } return Accepted(); From b21b402b241ec1198cf0454f8bf65cfcc91a21e5 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 19:04:39 +0200 Subject: [PATCH 28/32] =?UTF-8?q?=F0=9F=90=9B=20Audit=20an=20edit=20only?= =?UTF-8?q?=20after=20the=20edited=20message=20is=20dispatched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-message edit entry was emitted right after the failed message was marked resolved but before the edited message was dispatched, so a dispatch failure (transport down) left a success entry for an edit whose message never went anywhere — repeated on every redelivery. The entry is now emitted after the dispatch, so each audit entry corresponds to an actual dispatch of the edited message. --- .../Recoverability/EditHandlerAuditTests.cs | 14 ++++++++++++++ .../Recoverability/EditMessageTests.cs | 7 +++++++ .../Recoverability/Editing/EditHandler.cs | 14 ++++++++------ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs index 8eb35f0f8b..a22a45e2fd 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs @@ -55,6 +55,20 @@ public async Task Successful_edit_is_audited_with_the_initiating_user() } } + [Test] + public async Task Edit_that_fails_to_dispatch_is_not_audited() + { + var user = new AuditUser("alice-sub", "Alice"); + var failedMessage = await CreateAndStoreFailedMessage(); + var message = CreateEditMessage(failedMessage.UniqueMessageId); + dispatcher.ThrowOnDispatch = new InvalidOperationException("simulated dispatch failure"); + + var context = new TestableMessageHandlerContext { MessageHeaders = StampedHeaders(user, "op-edit") }; + Assert.ThrowsAsync(() => handler.Handle(message, context)); + + Assert.That(audit.Messages, Is.Empty, "an edit whose message was never dispatched must not be audited as done"); + } + static System.Collections.Generic.Dictionary StampedHeaders(AuditUser user, string operationId) => new() { [AuditHeaders.SubjectId] = user.Id, diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs index d426f2da15..74f62ef85f 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs @@ -271,8 +271,15 @@ public sealed class TestableUnicastDispatcher : IMessageDispatcher { public List<(UnicastTransportOperation, TransportTransaction)> DispatchedMessages { get; } = []; + public Exception ThrowOnDispatch { get; set; } + public Task Dispatch(TransportOperations outgoingMessages, TransportTransaction transaction, CancellationToken cancellationToken) { + if (ThrowOnDispatch != null) + { + throw ThrowOnDispatch; + } + DispatchedMessages.AddRange(outgoingMessages.UnicastTransportOperations.Select(m => (m, transaction))); return Task.CompletedTask; } diff --git a/src/ServiceControl/Recoverability/Editing/EditHandler.cs b/src/ServiceControl/Recoverability/Editing/EditHandler.cs index ce52d77cac..1ff4ca428f 100644 --- a/src/ServiceControl/Recoverability/Editing/EditHandler.cs +++ b/src/ServiceControl/Recoverability/Editing/EditHandler.cs @@ -58,12 +58,6 @@ public async Task Handle(EditAndSend message, IMessageHandlerContext context) await session.SaveChanges(); } - var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); - if (!string.IsNullOrEmpty(operationId)) - { - auditLog.MessageAction(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, message.FailedMessageId, operationId); - } - var redirects = await redirectsStore.GetOrCreate(); var attempt = failedMessage.ProcessingAttempts.Last(); @@ -82,6 +76,14 @@ public async Task Handle(EditAndSend message, IMessageHandlerContext context) } await DispatchEditedMessage(outgoingMessage, address, context); + // Audited only after the edited message is really dispatched. A dispatch failure is + // redelivered and dispatches again, so each audit entry matches an actual dispatch. + var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); + if (!string.IsNullOrEmpty(operationId)) + { + auditLog.MessageAction(user, MessageActionKind.Edit, Permissions.ErrorMessagesEdit, MessageActionScope.Single, message.FailedMessageId, operationId); + } + await domainEvents.Raise(new MessageEditedAndRetried { FailedMessageId = message.FailedMessageId From 4ab9d35378294fd98dabe4eab3ebe8e2059274c3 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 19:09:50 +0200 Subject: [PATCH 29/32] =?UTF-8?q?=F0=9F=90=9B=20Carry=20the=20batch=20scop?= =?UTF-8?q?e=20on=20ArchiveMessage=20per-message=20audit=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch archive fans out into one ArchiveMessage command per id, and the handler hardcoded scope 'single' on the per-message entries while the operation entry said 'batch' — the only path where per-message scope did not match the originating operation (unarchive batch/range, group and retry paths all propagate it). The command now carries the operation's scope; the default (Single) keeps legacy in-flight commands and the single-archive endpoint correct. --- .../MessageFailures/ArchiveScopeAuditTests.cs | 69 +++++++++++++++++++ .../AsyncRangeAndQueueAuditTests.cs | 2 +- .../Api/ArchiveMessagesController.cs | 4 +- .../Handlers/ArchiveMessageHandler.cs | 2 +- .../InternalMessages/ArchiveMessage.cs | 6 ++ 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.UnitTests/MessageFailures/ArchiveScopeAuditTests.cs diff --git a/src/ServiceControl.UnitTests/MessageFailures/ArchiveScopeAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/ArchiveScopeAuditTests.cs new file mode 100644 index 0000000000..e6772ee77a --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/ArchiveScopeAuditTests.cs @@ -0,0 +1,69 @@ +#nullable enable +namespace ServiceControl.UnitTests.MessageFailures; + +using System.Linq; +using System.Threading.Tasks; +using NServiceBus.Testing; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.MessageFailures; +using ServiceControl.MessageFailures.Api; +using ServiceControl.MessageFailures.Handlers; +using ServiceControl.MessageFailures.InternalMessages; +using ServiceControl.UnitTests.Operations; +using ServiceControl.UnitTests.Recoverability; + +/// +/// A batch archive fans out into one ArchiveMessage command per id. The per-message audit entries +/// must carry the originating operation's scope — like every other path (unarchive batch/range, +/// group, retry) — not a hardcoded "single". +/// +[TestFixture] +public class ArchiveScopeAuditTests +{ + static readonly AuditUser User = new("alice-sub", "Alice"); + + [Test] + public async Task Batch_archive_commands_carry_the_batch_scope() + { + var session = new TestableMessageSession(); + var controller = new ArchiveMessagesController(session, null!, new StubCurrentUserAccessor(User), new RecordingMessageActionAuditLog()); + + await controller.ArchiveBatch(["m-1", "m-2"]); + + var scopes = session.SentMessages.Select(s => ((ArchiveMessage)s.Message).Scope).ToArray(); + Assert.That(scopes, Has.Length.EqualTo(2).And.All.EqualTo(MessageActionScope.Batch)); + } + + [Test] + public async Task Single_archive_command_carries_the_single_scope() + { + var session = new TestableMessageSession(); + var controller = new ArchiveMessagesController(session, null!, new StubCurrentUserAccessor(User), new RecordingMessageActionAuditLog()); + + await controller.Archive("m-1"); + + Assert.That(((ArchiveMessage)session.SentMessages.Single().Message).Scope, Is.EqualTo(MessageActionScope.Single)); + } + + [Test] + public async Task Archived_message_is_audited_with_the_scope_of_the_originating_operation() + { + var audit = new RecordingMessageActionAuditLog(); + var store = new AsyncRangeAndQueueAuditTests.StubErrorMessageDataStore { ErrorByResult = new FailedMessage { Status = FailedMessageStatus.Unresolved } }; + var handler = new ArchiveMessageHandler(store, new FakeDomainEvents(), audit); + + var context = new TestableMessageHandlerContext + { + MessageHeaders = + { + [AuditHeaders.SubjectId] = User.Id, + [AuditHeaders.SubjectName] = User.Name, + [AuditHeaders.OperationId] = "op-batch" + } + }; + await handler.Handle(new ArchiveMessage { FailedMessageId = "m-1", Scope = MessageActionScope.Batch }, context); + + Assert.That(audit.Messages.Single().Scope, Is.EqualTo(MessageActionScope.Batch)); + } +} diff --git a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs index 62e5e8cd88..ffb2dcae62 100644 --- a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs +++ b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs @@ -152,7 +152,7 @@ public async Task Unarchive_by_range_audits_each_message_with_bare_id() } } - sealed class StubErrorMessageDataStore : IErrorMessageDataStore + internal sealed class StubErrorMessageDataStore : IErrorMessageDataStore { public string[] RetryPendingMessagesResult { get; set; } = []; public string[] UnArchiveByRangeResult { get; set; } = []; diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index 8bcba65ca9..d00451129b 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -35,7 +35,7 @@ await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.Err { foreach (var id in messageIds) { - await messageSession.Send(new ArchiveMessage { FailedMessageId = id }, AuditHeaders.LocalSendOptions(user, operationId)); + await messageSession.Send(new ArchiveMessage { FailedMessageId = id, Scope = MessageActionScope.Batch }, AuditHeaders.LocalSendOptions(user, operationId)); } }); @@ -64,7 +64,7 @@ public async Task Archive(string messageId) var operationId = this.AuditOperationId(); await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, resource: messageId, count: 1, operationId: operationId, - () => messageSession.Send(m => m.FailedMessageId = messageId, AuditHeaders.LocalSendOptions(user, operationId))); + () => messageSession.Send(new ArchiveMessage { FailedMessageId = messageId, Scope = MessageActionScope.Single }, AuditHeaders.LocalSendOptions(user, operationId))); return Accepted(); } diff --git a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs index 2713abb4cf..6169f2b4aa 100644 --- a/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs +++ b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs @@ -29,7 +29,7 @@ await domainEvents.Raise(new FailedMessageArchived var (user, operationId) = AuditHeaders.Read(context.MessageHeaders); if (!string.IsNullOrEmpty(operationId)) { - auditLog.MessageAction(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, failedMessageId, operationId); + auditLog.MessageAction(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, message.Scope, failedMessageId, operationId); } } } diff --git a/src/ServiceControl/MessageFailures/InternalMessages/ArchiveMessage.cs b/src/ServiceControl/MessageFailures/InternalMessages/ArchiveMessage.cs index 1043984455..748af7a82d 100644 --- a/src/ServiceControl/MessageFailures/InternalMessages/ArchiveMessage.cs +++ b/src/ServiceControl/MessageFailures/InternalMessages/ArchiveMessage.cs @@ -1,9 +1,15 @@ namespace ServiceControl.MessageFailures.InternalMessages { + using Infrastructure.Auth; using NServiceBus; class ArchiveMessage : ICommand { public string FailedMessageId { get; set; } + + // Scope of the originating operation, carried so the per-message audit entry emitted when + // the message is really archived matches the operation entry (single vs batch). Defaults to + // Single for legacy in-flight commands. + public MessageActionScope Scope { get; set; } } } \ No newline at end of file From b1ce6b1b93055d222fb36b2ede6187edb901a6d9 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 19:33:24 +0200 Subject: [PATCH 30/32] =?UTF-8?q?=F0=9F=90=9B=20Deflake=20AllMessagesInUnA?= =?UTF-8?q?rchivedGroupShouldNotExpire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test archived group B and immediately unarchived it, but UnarchiveDocumentManager.GetGroupDetails reads ArchivedGroupsViewIndex, which is async — on a slow runner the index hadn't seen the archive yet, so the unarchive logged 'No messages to unarchive' and no-opped, message B expired along with A, and the wait for exactly one remaining message timed out (observed on Linux-PrimaryRavenPersistence; Windows passed). Wait for indexing between the archive and the unarchive, like the test already does after ingestion. --- .../Expiration/MessageExpiryTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/MessageExpiryTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/MessageExpiryTests.cs index e7c9a45400..7e8ca92206 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/MessageExpiryTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/MessageExpiryTests.cs @@ -82,6 +82,11 @@ public async Task AllMessagesInUnArchivedGroupShouldNotExpire() await ArchiveMessages.ArchiveAllInGroup(groupIdA); await ArchiveMessages.ArchiveAllInGroup(groupIdB); + + // Let ArchivedGroupsViewIndex catch up with the archive, or the unarchive silently + // no-ops ("No messages to unarchive") and message B wrongly expires. + CompleteDatabaseOperation(); + await ArchiveMessages.UnarchiveAllInGroup(groupIdB); await EnableExpiration(); From 252e3334eb1d063eb13e88514496c6b67dac1a50 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 19:48:36 +0200 Subject: [PATCH 31/32] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Skip=20building=20EC?= =?UTF-8?q?S=20audit=20documents=20for=20filtered-out=20categories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageAction/Operation built the full ECS JSON document (timestamp formatting, anonymous object graph, reflection-based serialization) before the source-generated log method's internal IsEnabled check — wasted work for every message of a bulk retry/archive when the operator filters the high-volume ServiceControl.Audit.Messages category, which the class explicitly supports. The level check now runs first. Also shares the single EcsJsonOptions with AuthorizationAuditLog (the PR already had to retrofit WhenWritingNull onto one copy to keep the two streams consistent) and replaces the per-entry scope ToString().ToLowerInvariant() with constant names. Tests pin the contracts these changes could break: per-outcome level semantics under a Warning category filter, and the exact lowercase scope mapping for every MessageActionScope value. --- .../Auth/MessageActionAuditLogTests.cs | 50 ++++++++++++++++++- .../Auth/AuthorizationAuditLog.cs | 5 +- .../Auth/MessageActionAuditLog.cs | 39 ++++++++++++--- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs index 384b32814f..f2092f5022 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs @@ -9,10 +9,14 @@ namespace ServiceControl.Infrastructure.Tests.Auth; [TestFixture] public class MessageActionAuditLogTests { - static (RecordingLoggerProvider provider, MessageActionAuditLog log) Create() + static (RecordingLoggerProvider provider, MessageActionAuditLog log) Create(System.Action? configure = null) { var provider = new RecordingLoggerProvider(); - var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var factory = LoggerFactory.Create(b => + { + b.AddProvider(provider); + configure?.Invoke(b); + }); return (provider, new MessageActionAuditLog(factory)); } @@ -102,6 +106,48 @@ public void Null_valued_fields_are_omitted() } } + [Test] + public void Success_entries_are_suppressed_when_category_minimum_level_is_warning() + { + var (provider, log) = Create(b => b.AddFilter(MessageActionAuditLog.MessageCategory, LogLevel.Warning)); + + log.MessageAction(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry", + MessageActionScope.Batch, messageId: "m-1", operationId: "op-6"); + + Assert.That(provider.EntriesFor(MessageActionAuditLog.MessageCategory), Is.Empty); + } + + [Test] + public void Failure_entries_are_still_emitted_when_category_minimum_level_is_warning() + { + var (provider, log) = Create(b => b.AddFilter(MessageActionAuditLog.MessageCategory, LogLevel.Warning)); + + log.MessageAction(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry", + MessageActionScope.Batch, messageId: "m-1", operationId: "op-7", success: false); + + var entries = provider.EntriesFor(MessageActionAuditLog.MessageCategory); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning)); + } + + [TestCase(MessageActionScope.Single, "single")] + [TestCase(MessageActionScope.Batch, "batch")] + [TestCase(MessageActionScope.Group, "group")] + [TestCase(MessageActionScope.Queue, "queue")] + [TestCase(MessageActionScope.Endpoint, "endpoint")] + [TestCase(MessageActionScope.All, "all")] + [TestCase(MessageActionScope.Range, "range")] + public void Scope_serializes_as_its_lowercase_name(MessageActionScope scope, string expected) + { + var (provider, log) = Create(); + + log.Operation(AuditUser.Anonymous, MessageActionKind.Retry, "error:messages:retry", + scope, resource: null, count: null, operationId: "op-8"); + + var ecs = JsonDocument.Parse(provider.EntriesFor(MessageActionAuditLog.OperationCategory)[0].Message).RootElement; + Assert.That(ecs.GetProperty("servicecontrol").GetProperty("scope").GetString(), Is.EqualTo(expected)); + } + [TestCase(null, "op")] [TestCase("", "op")] [TestCase("error:messages:retry", null)] diff --git a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs index 38718c0fa4..0b4a993a8e 100644 --- a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -19,8 +19,9 @@ public sealed partial class AuthorizationAuditLog(ILoggerFactory loggerFactory) readonly ILogger logger = loggerFactory.CreateLogger(AuditCategory); // Relaxed escaping keeps the JSON readable for log sinks (no \uXXXX for '+', '<', accented names, …); - // the HTML-safe default only matters in a browser context, which an audit log is not. - static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + // the HTML-safe default only matters in a browser context, which an audit log is not. Shared with + // MessageActionAuditLog so both ECS streams keep the same serialization contract. + internal static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) { diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs index 862082a3ca..c14994b231 100644 --- a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs @@ -3,9 +3,7 @@ namespace ServiceControl.Infrastructure.Auth; using System; using System.Collections.Generic; -using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; /// @@ -19,9 +17,6 @@ public sealed partial class MessageActionAuditLog : IMessageActionAuditLog public const string OperationCategory = AuthorizationAuditLog.AuditCategory; // "ServiceControl.Audit" public const string MessageCategory = AuthorizationAuditLog.AuditCategory + ".Messages"; // "ServiceControl.Audit.Messages" - // Relaxed escaping keeps the JSON readable for log sinks, matching AuthorizationAuditLog. - static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - readonly ILogger operationLogger; readonly ILogger messageLogger; @@ -36,6 +31,14 @@ public void Operation(AuditUser user, MessageActionKind kind, string permission, ArgumentException.ThrowIfNullOrEmpty(permission); ArgumentException.ThrowIfNullOrEmpty(operationId); + // Checked before building the ECS document: the generated log methods only check IsEnabled + // after the message arguments are evaluated, and the document is not worth building for a + // filtered-out category. + if (!operationLogger.IsEnabled(success ? LogLevel.Information : LogLevel.Warning)) + { + return; + } + var ecs = BuildEcsEvent(user, kind, permission, scope, resource, messageId: null, count, operationId, success); if (success) @@ -54,6 +57,14 @@ public void MessageAction(AuditUser user, MessageActionKind kind, string permiss ArgumentException.ThrowIfNullOrEmpty(messageId); ArgumentException.ThrowIfNullOrEmpty(operationId); + // Bulk operations emit one entry per message on hot paths (retry staging, archive batches), + // and operators are told they can filter this category — skip the document build entirely + // when the entry would be dropped. + if (!messageLogger.IsEnabled(success ? LogLevel.Information : LogLevel.Warning)) + { + return; + } + var ecs = BuildEcsEvent(user, kind, permission, scope, resource: null, messageId, count: null, operationId, success); if (success) @@ -87,7 +98,7 @@ static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permi ["servicecontrol"] = new { permission, - scope = scope.ToString().ToLowerInvariant(), + scope = ScopeName(scope), resource, message = messageId is null ? null : new { id = messageId }, count, @@ -95,9 +106,23 @@ static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permi } }; - return JsonSerializer.Serialize(ecs, EcsJsonOptions); + return JsonSerializer.Serialize(ecs, AuthorizationAuditLog.EcsJsonOptions); } + // Constant lowercase names instead of ToString().ToLowerInvariant(): per-message entries call this + // once per message in bulk loops, and the two throwaway strings per entry add up. + static string ScopeName(MessageActionScope scope) => scope switch + { + MessageActionScope.Single => "single", + MessageActionScope.Batch => "batch", + MessageActionScope.Group => "group", + MessageActionScope.Queue => "queue", + MessageActionScope.Endpoint => "endpoint", + MessageActionScope.All => "all", + MessageActionScope.Range => "range", + _ => scope.ToString().ToLowerInvariant() + }; + [LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "{AuditEvent}")] static partial void LogOperation(ILogger logger, string auditEvent); From df2f4ed9b4472987a99b9b75a90d88cea899db24 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 20:42:20 +0200 Subject: [PATCH 32/32] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Export=20the=20audit?= =?UTF-8?q?=20documents=20as=20the=20OTLP=20record=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit entries were logged through source-generated methods with the ECS JSON as a '{AuditEvent}' template parameter. Over OTLP that exports the literal '{AuditEvent}' placeholder as the record body with the document only in an attribute — backends that map body → message (e.g. Elastic) show the placeholder and need a pipeline rule to lift the JSON. Both audit logs now log the pre-rendered document as a plain-string state: the OTLP record carries the JSON exactly once, as the body, with no attributes. The NLog audit.json line and the ILogger contract (categories, levels, event ids 1001/1002/2001/2002) are unchanged. Verified against an OTel collector and against audit.json on disk. --- .../Auth/AuthorizationAuditLog.cs | 29 +++++------ .../Auth/MessageActionAuditLog.cs | 51 ++++++------------- 2 files changed, 29 insertions(+), 51 deletions(-) diff --git a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs index 0b4a993a8e..83e75e0f7c 100644 --- a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Infrastructure.Auth; /// Default that emits every decision as a structured log entry on /// the stable category ServiceControl.Audit. Sinks filter on the category, not on the type name. /// -public sealed partial class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAuthorizationAuditLog +public sealed class AuthorizationAuditLog(ILoggerFactory loggerFactory) : IAuthorizationAuditLog { public const string AuditCategory = "ServiceControl.Audit"; // Logger name is used in logging configuration to write audit entries to a separate file. @@ -30,16 +30,14 @@ public void Decision(string subjectId, string subjectName, string permission, st ArgumentException.ThrowIfNullOrEmpty(permission); ArgumentException.ThrowIfNullOrEmpty(reason); - var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason); - - if (allowed) - { - LogAllow(logger, auditEvent); - } - else + var level = allowed ? LogLevel.Information : LogLevel.Warning; + if (!logger.IsEnabled(level)) { - LogDeny(logger, auditEvent); + return; } + + var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason); + logger.Log(level, allowed ? AllowEventId : DenyEventId, auditEvent, null, IdentityFormatter); } // Serialises one authorization decision as an Elastic Common Schema (ECS) document so it ingests into @@ -75,11 +73,10 @@ static string BuildEcsEvent(string subjectId, string subjectName, string permiss return JsonSerializer.Serialize(ecs, EcsJsonOptions); } - // Source-generated structured log methods — the audit event is the pre-rendered ECS JSON document. Allow - // and deny differ only by level so sinks can alert on denies (Warning) without parsing the payload. - [LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "{AuditEvent}")] - static partial void LogAllow(ILogger logger, string auditEvent); - - [LoggerMessage(EventId = 1002, Level = LogLevel.Warning, Message = "{AuditEvent}")] - static partial void LogDeny(ILogger logger, string auditEvent); + // The audit event is the pre-rendered ECS JSON document, logged as a plain-string state so it is + // exported over OTLP as the record body (see MessageActionAuditLog for the full rationale). Allow + // and deny differ by level so sinks can alert on denies (Warning) without parsing the payload. + static readonly EventId AllowEventId = new(1001); + static readonly EventId DenyEventId = new(1002); + static readonly Func IdentityFormatter = static (state, _) => state; } diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs index c14994b231..abe63658b4 100644 --- a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Infrastructure.Auth; /// sub-category so operators can filter the high-volume per-message stream /// independently through standard logging configuration. /// -public sealed partial class MessageActionAuditLog : IMessageActionAuditLog +public sealed class MessageActionAuditLog : IMessageActionAuditLog { public const string OperationCategory = AuthorizationAuditLog.AuditCategory; // "ServiceControl.Audit" public const string MessageCategory = AuthorizationAuditLog.AuditCategory + ".Messages"; // "ServiceControl.Audit.Messages" @@ -31,24 +31,15 @@ public void Operation(AuditUser user, MessageActionKind kind, string permission, ArgumentException.ThrowIfNullOrEmpty(permission); ArgumentException.ThrowIfNullOrEmpty(operationId); - // Checked before building the ECS document: the generated log methods only check IsEnabled - // after the message arguments are evaluated, and the document is not worth building for a - // filtered-out category. - if (!operationLogger.IsEnabled(success ? LogLevel.Information : LogLevel.Warning)) + // Checked before building the ECS document — not worth building for a filtered-out category. + var level = success ? LogLevel.Information : LogLevel.Warning; + if (!operationLogger.IsEnabled(level)) { return; } var ecs = BuildEcsEvent(user, kind, permission, scope, resource, messageId: null, count, operationId, success); - - if (success) - { - LogOperation(operationLogger, ecs); - } - else - { - LogOperationFailure(operationLogger, ecs); - } + operationLogger.Log(level, OperationEventId, ecs, null, IdentityFormatter); } public void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true) @@ -60,21 +51,14 @@ public void MessageAction(AuditUser user, MessageActionKind kind, string permiss // Bulk operations emit one entry per message on hot paths (retry staging, archive batches), // and operators are told they can filter this category — skip the document build entirely // when the entry would be dropped. - if (!messageLogger.IsEnabled(success ? LogLevel.Information : LogLevel.Warning)) + var level = success ? LogLevel.Information : LogLevel.Warning; + if (!messageLogger.IsEnabled(level)) { return; } var ecs = BuildEcsEvent(user, kind, permission, scope, resource: null, messageId, count: null, operationId, success); - - if (success) - { - LogMessage(messageLogger, ecs); - } - else - { - LogMessageFailure(messageLogger, ecs); - } + messageLogger.Log(level, MessageEventId, ecs, null, IdentityFormatter); } static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, string? messageId, int? count, string operationId, bool success) @@ -123,15 +107,12 @@ static string BuildEcsEvent(AuditUser user, MessageActionKind kind, string permi _ => scope.ToString().ToLowerInvariant() }; - [LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "{AuditEvent}")] - static partial void LogOperation(ILogger logger, string auditEvent); - - [LoggerMessage(EventId = 2001, Level = LogLevel.Warning, Message = "{AuditEvent}")] - static partial void LogOperationFailure(ILogger logger, string auditEvent); - - [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "{AuditEvent}")] - static partial void LogMessage(ILogger logger, string auditEvent); - - [LoggerMessage(EventId = 2002, Level = LogLevel.Warning, Message = "{AuditEvent}")] - static partial void LogMessageFailure(ILogger logger, string auditEvent); + // Logged with the pre-rendered document as the state, not as a "{AuditEvent}" template parameter: + // a parameterized message is exported over OTLP with the literal "{AuditEvent}" placeholder as the + // record body and the JSON only in an attribute — backends that map body → message show the + // placeholder. A plain-string state exports the document exactly once, as the record body; NLog is + // unaffected either way and writes the same line to audit.json. + static readonly EventId OperationEventId = new(2001); + static readonly EventId MessageEventId = new(2002); + static readonly Func IdentityFormatter = static (state, _) => state; }