From 6125b09f73c72655f8d7d5aa738496d59c2621a8 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 2 Jun 2026 11:32:49 +0200 Subject: [PATCH 01/53] Add permissions catalog with constants for access control Add Authorize attribute to all controller APIs --- .../Particular.LicensingComponent.csproj | 1 + .../WebApi/LicensingController.cs | 10 ++ .../MessagesView/GetMessages2Controller.cs | 3 + .../MessagesView/GetMessagesController.cs | 8 + .../MessagesConversationController.cs | 3 + .../Connection/ConnectionController.cs | 3 + .../KnownEndpointsController.cs | 3 + .../SagaAudit/SagasController.cs | 3 + .../Auth/Permissions.cs | 137 ++++++++++++++++++ .../Connection/ConnectionController.cs | 3 + .../Http/Diagrams/DiagramApiController.cs | 6 + .../Http/LicenseController.cs | 3 + .../Messages/GetMessages2Controller.cs | 3 + .../GetMessagesByConversationController.cs | 3 + .../Messages/GetMessagesController.cs | 10 ++ .../Connection/ConnectionController.cs | 3 + .../CustomChecks/Web/CustomCheckController.cs | 4 + .../EventLog/EventLogApiController.cs | 3 + .../Licensing/LicenseController.cs | 3 + .../Api/ArchiveMessagesController.cs | 6 + .../Api/EditFailedMessagesController.cs | 4 + .../Api/GetAllErrorsController.cs | 6 + .../Api/GetErrorByIdController.cs | 4 + .../Api/PendingRetryMessagesController.cs | 4 + .../Api/QueueAddressController.cs | 4 + .../Api/ResolveMessagesController.cs | 4 + .../Api/RetryMessagesController.cs | 7 + .../Api/UnArchiveMessagesController.cs | 4 + .../Api/MessageRedirectsController.cs | 7 + .../Web/EndpointsMonitoringController.cs | 6 + .../Web/EndpointsSettingsController.cs | 4 + .../Api/NotificationsController.cs | 6 + .../API/FailureGroupsArchiveController.cs | 3 + .../API/FailureGroupsController.cs | 10 ++ .../API/FailureGroupsRetryController.cs | 3 + .../API/FailureGroupsUnarchiveController.cs | 3 + .../API/UnacknowledgedGroupsController.cs | 3 + .../SagaAudit/SagasController.cs | 3 + 38 files changed, 303 insertions(+) create mode 100644 src/ServiceControl.Infrastructure/Auth/Permissions.cs diff --git a/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj b/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj index 4f87cd4a63..d1136120d9 100644 --- a/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj +++ b/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 0b094fbb94..f898c0cc72 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -5,10 +5,12 @@ using System.Text.Json; using System.Threading; using Contracts; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Particular.LicensingComponent.Report; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api/licensing")] @@ -19,6 +21,7 @@ public LicensingController(IThroughputCollector throughputCollector) this.throughputCollector = throughputCollector; } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("endpoints")] [HttpGet] public async Task> GetEndpointThroughput(CancellationToken cancellationToken) @@ -26,6 +29,7 @@ public async Task> GetEndpointThroughput(Cancell return await throughputCollector.GetThroughputSummary(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputManage)] [Route("endpoints/update")] [HttpPost] public async Task UpdateUserSelectionOnEndpointThroughput(List updateUserIndicators, CancellationToken cancellationToken) @@ -34,6 +38,7 @@ public async Task UpdateUserSelectionOnEndpointThroughput(List CanThroughputReportBeGenerated(CancellationToken cancellationToken) @@ -41,6 +46,7 @@ public async Task CanThroughputReportBeGenerated(Cancella return await throughputCollector.GetReportGenerationState(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("report/file")] [HttpGet] public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken) @@ -77,6 +83,7 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/info")] [HttpGet] public async Task GetThroughputSettingsInformation(CancellationToken cancellationToken) @@ -84,10 +91,12 @@ public async Task GetThroughputSettingsInformation return await throughputCollector.GetThroughputConnectionSettingsInformation(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/test")] [HttpGet] public async Task TestThroughputConnectionSettings(CancellationToken cancellationToken) => await throughputCollector.TestConnectionSettings(cancellationToken); + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/masks")] [HttpGet] public async Task> GetMasks(CancellationToken cancellationToken) @@ -95,6 +104,7 @@ public async Task> GetMasks(CancellationToken cancellationToken) return await throughputCollector.GetReportMasks(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputManage)] [Route("settings/masks/update")] [HttpPost] public async Task UpdateMasks(List updateMasks, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs index db187bcd77..5027f7f43f 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView; using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; +using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class GetMessages2Controller(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages2")] [HttpGet] public async Task> GetAllMessages( diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs index 7a81b7294d..e88d1fbb6f 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs @@ -9,11 +9,13 @@ namespace ServiceControl.Audit.Auditing.MessagesView using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class GetMessagesController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages")] [HttpGet] public async Task> GetAllMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, CancellationToken cancellationToken) @@ -23,6 +25,7 @@ public async Task> GetAllMessages([FromQuery] PagingInfo pag return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages")] [HttpGet] public async Task> GetEndpointMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, string endpoint, CancellationToken cancellationToken) @@ -43,6 +46,7 @@ public async Task> GetEndpointAuditCounts([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/{id}/body")] [HttpGet] public async Task Get(string id, CancellationToken cancellationToken) @@ -69,6 +73,7 @@ public async Task Get(string id, CancellationToken cancellationTo return result.StringContent != null ? Content(result.StringContent, contentType) : File(result.StreamContent, contentType); } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string q, CancellationToken cancellationToken) @@ -78,6 +83,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string keyword, CancellationToken cancellationToken) @@ -87,6 +93,7 @@ public async Task> SearchByKeyWord([FromQuery] PagingInfo pa return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string q, CancellationToken cancellationToken) @@ -96,6 +103,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyword([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string keyword, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs b/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs index 4fcc04cd88..7ba20c1a11 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class MessagesConversationController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("conversations/{conversationId}")] [HttpGet] public async Task> Get([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string conversationId, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Connection/ConnectionController.cs b/src/ServiceControl.Audit/Connection/ConnectionController.cs index 75daeb593d..8d8d0fa4dc 100644 --- a/src/ServiceControl.Audit/Connection/ConnectionController.cs +++ b/src/ServiceControl.Audit/Connection/ConnectionController.cs @@ -2,7 +2,9 @@ namespace ServiceControl.Audit.Connection { using System.Text.Json; using Infrastructure.Settings; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] @@ -11,6 +13,7 @@ public class ConnectionController(Settings settings) : ControllerBase // This controller doesn't use the default serialization settings because // ServicePulse and the Platform Connector Plugin expect the connection // details the be serialized and formatted in a specific way + [Authorize(Policy = Permissions.AuditConnectionView)] [Route("connection")] [HttpGet] public IActionResult GetConnectionDetails() => diff --git a/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs b/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs index 2ed0b2a278..3b7fd5871b 100644 --- a/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs +++ b/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Monitoring using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class KnownEndpointsController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditEndpointView)] [Route("endpoints/known")] [HttpGet] public async Task> GetAll([FromQuery] PagingInfo pagingInfo, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/SagaAudit/SagasController.cs b/src/ServiceControl.Audit/SagaAudit/SagasController.cs index cd2356784d..3d92d5f913 100644 --- a/src/ServiceControl.Audit/SagaAudit/SagasController.cs +++ b/src/ServiceControl.Audit/SagaAudit/SagasController.cs @@ -5,14 +5,17 @@ namespace ServiceControl.Audit.SagaAudit using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; using ServiceControl.SagaAudit; [ApiController] [Route("api")] public class SagasController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditSagaView)] [Route("sagas/{id}")] [HttpGet] public async Task Sagas([FromQuery] PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Infrastructure/Auth/Permissions.cs b/src/ServiceControl.Infrastructure/Auth/Permissions.cs new file mode 100644 index 0000000000..006fccf0f5 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Permissions.cs @@ -0,0 +1,137 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Reflection; + +/// +/// Catalogue of all known permission constants in the format instance:resource:action. +/// Each ServiceControl instance (error/audit/monitoring) is a separate process and namespaces its +/// permissions with an instance prefix. +/// +/// The set is automatically derived from all public const string +/// fields on this class, so adding a new constant is sufficient — no separate registration needed. +/// +/// +public static class Permissions +{ + // ───────────────────────────── Error instance (Primary) ───────────────────────────── + + /// Messages area — viewing, retrying, archiving, and editing failed messages. + public const string ErrorMessagesView = "error:messages:view"; + /// + public const string ErrorMessagesRetry = "error:messages:retry"; + /// + public const string ErrorMessagesArchive = "error:messages:archive"; + /// + public const string ErrorMessagesUnarchive = "error:messages:unarchive"; + /// + public const string ErrorMessagesEdit = "error:messages:edit"; + + /// Recoverability groups area — viewing, retrying, archiving, and unarchiving failure groups. + public const string ErrorRecoverabilityGroupsView = "error:recoverabilitygroups:view"; + /// + public const string ErrorRecoverabilityGroupsRetry = "error:recoverabilitygroups:retry"; + /// + public const string ErrorRecoverabilityGroupsArchive = "error:recoverabilitygroups:archive"; + /// + public const string ErrorRecoverabilityGroupsUnarchive = "error:recoverabilitygroups:unarchive"; + + /// Endpoints area — viewing, managing, and deleting monitored endpoints. + public const string ErrorEndpointsView = "error:endpoints:view"; + /// + public const string ErrorEndpointsManage = "error:endpoints:manage"; + /// + public const string ErrorEndpointsDelete = "error:endpoints:delete"; + + /// Heartbeats area — viewing heartbeat status for endpoints. + public const string ErrorHeartbeatsView = "error:heartbeats:view"; + + /// Custom checks area — viewing and deleting custom check results. + public const string ErrorCustomChecksView = "error:customchecks:view"; + /// + public const string ErrorCustomChecksDelete = "error:customchecks:delete"; + + /// Sagas area — viewing saga audit data. + public const string ErrorSagasView = "error:sagas:view"; + + /// Event log area — viewing the event log. + public const string ErrorEventLogView = "error:eventlog:view"; + + /// Licensing area — viewing and managing license configuration. + public const string ErrorLicensingView = "error:licensing:view"; + /// + public const string ErrorLicensingManage = "error:licensing:manage"; + + /// Notifications area — viewing, managing, and testing notification settings. + public const string ErrorNotificationsView = "error:notifications:view"; + /// + public const string ErrorNotificationsManage = "error:notifications:manage"; + /// + public const string ErrorNotificationsTest = "error:notifications:test"; + + /// Retry redirects area — viewing and managing message redirect rules. + public const string ErrorRedirectsView = "error:redirects:view"; + /// + public const string ErrorRedirectsManage = "error:redirects:manage"; + + /// Queue addresses area — viewing and deleting queue address entries. + public const string ErrorQueuesView = "error:queues:view"; + /// + public const string ErrorQueuesDelete = "error:queues:delete"; + + /// Throughput area — viewing and managing throughput reports and settings. + public const string ErrorThroughputView = "error:throughput:view"; + /// + public const string ErrorThroughputManage = "error:throughput:manage"; + + /// Platform connections area — viewing and managing broker/platform connection settings. + public const string ErrorConnectionsView = "error:connections:view"; + /// + public const string ErrorConnectionsManage = "error:connections:manage"; + + // ───────────────────────────── Audit instance ───────────────────────────── + + /// Audit instance (separate process) — read-only audit message log. + public const string AuditMessageView = "audit:message:view"; + /// Audit instance — viewing platform connection details. + public const string AuditConnectionView = "audit:connection:view"; + /// Audit instance — viewing known endpoints. + public const string AuditEndpointView = "audit:endpoint:view"; + /// Audit instance — viewing saga audit data. + public const string AuditSagaView = "audit:saga:view"; + + // ───────────────────────────── Monitoring instance ───────────────────────────── + + /// Monitoring instance (separate process) — viewing endpoint metrics. + public const string MonitoringEndpointView = "monitoring:endpoint:view"; + /// Monitoring instance — removing a monitored endpoint instance. + public const string MonitoringEndpointDelete = "monitoring:endpoint:delete"; + /// Monitoring instance — viewing platform connection details. + public const string MonitoringConnectionView = "monitoring:connection:view"; + /// Monitoring instance — viewing license status. + public const string MonitoringLicenseView = "monitoring:license:view"; + + /// + /// The complete set of known permissions, derived from all public const string + /// fields declared on this class. Used by the policy provider and coverage tests. + /// + public static readonly IReadOnlySet All = BuildAll(); + + static IReadOnlySet BuildAll() + { + var set = new HashSet(); + foreach (var field in typeof(Permissions).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.IsLiteral && !field.IsInitOnly && field.FieldType == typeof(string)) + { + var value = (string?)field.GetValue(null); + if (value != null) + { + set.Add(value); + } + } + } + return set; + } +} diff --git a/src/ServiceControl.Monitoring/Connection/ConnectionController.cs b/src/ServiceControl.Monitoring/Connection/ConnectionController.cs index e765007b73..28978672a6 100644 --- a/src/ServiceControl.Monitoring/Connection/ConnectionController.cs +++ b/src/ServiceControl.Monitoring/Connection/ConnectionController.cs @@ -2,8 +2,10 @@ { using System; using System.Text.Json; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth; [ApiController] public class ConnectionController(ReceiveAddresses receiveAddresses) : ControllerBase @@ -11,6 +13,7 @@ public class ConnectionController(ReceiveAddresses receiveAddresses) : Controlle readonly string mainInputQueue = receiveAddresses.MainReceiveAddress; readonly TimeSpan defaultInterval = TimeSpan.FromSeconds(1); + [Authorize(Policy = Permissions.MonitoringConnectionView)] [Route("connection")] [HttpGet] public IActionResult GetConnectionDetails() => diff --git a/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs b/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs index 134bf92716..04bd58ac86 100644 --- a/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs +++ b/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs @@ -1,21 +1,26 @@ namespace ServiceControl.Monitoring.Http.Diagrams { using Infrastructure.Api; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; [ApiController] public class DiagramApiController(IEndpointMetricsApi endpointMetricsApi) : ControllerBase { + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints")] [HttpGet] public MonitoredEndpoint[] GetAllEndpointsMetrics([FromQuery] int? history = null) => endpointMetricsApi.GetAllEndpointsMetrics(history); + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints/{endpointName}")] [HttpGet] public ActionResult GetSingleEndpointMetrics(string endpointName, [FromQuery] int? history = null) => endpointMetricsApi.GetSingleEndpointMetrics(endpointName, history); + [Authorize(Policy = Permissions.MonitoringEndpointDelete)] [Route("monitored-instance/{endpointName}/{instanceId}")] [HttpDelete] public IActionResult DeleteEndpointInstance(string endpointName, string instanceId) @@ -25,6 +30,7 @@ public IActionResult DeleteEndpointInstance(string endpointName, string instance return Ok(); } + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints/disconnected")] [HttpGet] public ActionResult DisconnectedEndpointCount() => endpointMetricsApi.DisconnectedEndpointCount(); diff --git a/src/ServiceControl.Monitoring/Http/LicenseController.cs b/src/ServiceControl.Monitoring/Http/LicenseController.cs index d76e2e9361..55bcdfbbb5 100644 --- a/src/ServiceControl.Monitoring/Http/LicenseController.cs +++ b/src/ServiceControl.Monitoring/Http/LicenseController.cs @@ -1,11 +1,14 @@ namespace ServiceControl.Monitoring.Http { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Monitoring.Licensing; [ApiController] public class LicenseController(ActiveLicense activeLicense) : ControllerBase { + [Authorize(Policy = Permissions.MonitoringLicenseView)] [Route("license")] [HttpGet] public ActionResult License(bool refresh) diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs index 1ee79cfbbc..eb72656fb1 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs @@ -2,7 +2,9 @@ namespace ServiceControl.CompositeViews.Messages; using System.Collections.Generic; using System.Threading.Tasks; +using Infrastructure.Auth; using Infrastructure.WebApi; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -16,6 +18,7 @@ public class GetMessages2Controller( SearchEndpointApi searchEndpointApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages2")] [HttpGet] public async Task> Messages( diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs b/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs index 7bd650b453..ab18a6da9e 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -12,6 +14,7 @@ public class GetMessagesByConversationController(MessagesByConversationApi byConversationApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("conversations/{conversationId:required:minlength(1)}")] [HttpGet] public async Task> Messages([FromQuery] PagingInfo pagingInfo, diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs index e7133ba20a..d586fe34cd 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs @@ -5,8 +5,10 @@ namespace ServiceControl.CompositeViews.Messages using System.Net.Http; using System.Threading.Tasks; using Api.Contracts; + using Infrastructure.Auth; using Infrastructure.WebApi; using MessageCounting; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -33,6 +35,7 @@ public class GetMessagesController( ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages")] [HttpGet] public async Task> Messages([FromQuery] PagingInfo pagingInfo, @@ -48,6 +51,7 @@ public async Task> Messages([FromQuery] PagingInfo pagingInf return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages")] [HttpGet] public async Task> MessagesForEndpoint([FromQuery] PagingInfo pagingInfo, @@ -64,6 +68,7 @@ public async Task> MessagesForEndpoint([FromQuery] PagingInf } // the endpoint name is needed in the route to match the route and forward it as path and query to the remotes + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/audit-count")] [HttpGet] public async Task> GetEndpointAuditCounts([FromQuery] PagingInfo pagingInfo, string endpoint) @@ -75,6 +80,7 @@ public async Task> GetEndpointAuditCounts([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/{id}/body")] [HttpGet] public async Task Get(string id, [FromQuery(Name = "instance_id")] string instanceId) @@ -114,6 +120,7 @@ public async Task Get(string id, [FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -126,6 +133,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, @@ -139,6 +147,7 @@ public async Task> SearchByKeyWord([FromQuery] PagingInfo pa return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -151,6 +160,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyword([FromQuery] PagingInfo pagingInfo, diff --git a/src/ServiceControl/Connection/ConnectionController.cs b/src/ServiceControl/Connection/ConnectionController.cs index a85522a84c..b87d4d8dc7 100644 --- a/src/ServiceControl/Connection/ConnectionController.cs +++ b/src/ServiceControl/Connection/ConnectionController.cs @@ -4,6 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] @@ -13,6 +15,7 @@ public class ConnectionController(IPlatformConnectionBuilder builder) : Controll // This controller doesn't use the default serialization settings because // ServicePulse and the Platform Connector Plugin expect the connection // details the be serialized and formatted in a specific way + [Authorize(Policy = Permissions.ErrorConnectionsView)] [Route("connection")] [HttpGet] public async Task GetConnectionDetails() diff --git a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs index 0cc06bbd0c..f9fb690757 100644 --- a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs +++ b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Contracts.CustomChecks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -15,6 +17,7 @@ public class CustomCheckController(ICustomChecksDataStore checksDataStore, IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorCustomChecksView)] [Route("customchecks")] [HttpGet] public async Task> CustomChecks([FromQuery] PagingInfo pagingInfo, string status = null) @@ -27,6 +30,7 @@ public async Task> CustomChecks([FromQuery] PagingInfo paging return stats.Results; } + [Authorize(Policy = Permissions.ErrorCustomChecksDelete)] [Route("customchecks/{id}")] [HttpDelete] public async Task Delete(Guid id) diff --git a/src/ServiceControl/EventLog/EventLogApiController.cs b/src/ServiceControl/EventLog/EventLogApiController.cs index 1872154be7..605efc453d 100644 --- a/src/ServiceControl/EventLog/EventLogApiController.cs +++ b/src/ServiceControl/EventLog/EventLogApiController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class EventLogApiController(IEventLogDataStore logDataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorEventLogView)] [Route("eventlogitems")] [HttpGet] public async Task> Items([FromQuery] PagingInfo pagingInfo) diff --git a/src/ServiceControl/Licensing/LicenseController.cs b/src/ServiceControl/Licensing/LicenseController.cs index 15539d9db0..9f9e56cb6e 100644 --- a/src/ServiceControl/Licensing/LicenseController.cs +++ b/src/ServiceControl/Licensing/LicenseController.cs @@ -2,6 +2,8 @@ { using System.Threading; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Monitoring.HeartbeatMonitoring; using Particular.ServiceControl.Licensing; @@ -11,6 +13,7 @@ [Route("api")] public class LicenseController(ActiveLicense activeLicense, Settings settings, MassTransitConnectorHeartbeatStatus connectorHeartbeatStatus) : ControllerBase { + [Authorize(Policy = Permissions.ErrorLicensingView)] [HttpGet] [Route("license")] public async Task> License(bool refresh, string clientName, CancellationToken cancellationToken) diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index bad1ec4cf5..84384bec6f 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -2,8 +2,10 @@ namespace ServiceControl.MessageFailures.Api { using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -13,6 +15,7 @@ namespace ServiceControl.MessageFailures.Api [Route("api")] public class ArchiveMessagesController(IMessageSession messageSession, IErrorMessageDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/archive")] [HttpPost] [HttpPatch] @@ -34,6 +37,7 @@ public async Task ArchiveBatch(string[] messageIds) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/groups/{classifier?}")] [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace") @@ -45,6 +49,7 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex return Ok(results); } + [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/{messageId:required:minlength(1)}/archive")] [HttpPost] [HttpPatch] @@ -55,6 +60,7 @@ public async Task Archive(string messageId) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("archive/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task> GetGroup(string groupId, string status = default, string modified = default) diff --git a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs index 3ce42fc3ff..d5f5d587f6 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Text; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NServiceBus; @@ -21,10 +23,12 @@ public class EditFailedMessagesController( ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesEdit)] [Route("edit/config")] [HttpGet] public EditConfigurationModel Config() => GetEditConfiguration(); + [Authorize(Policy = Permissions.ErrorMessagesEdit)] [Route("edit/{failedMessageId:required:minlength(1)}")] [HttpPost] public async Task> Edit(string failedMessageId, [FromBody] EditMessageModel edit) diff --git a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs index 60f9f08ca9..06c7260d26 100644 --- a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class GetAllErrorsController(IErrorMessageDataStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors")] [HttpGet] public async Task> ErrorsGet([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string queueAddress) @@ -28,6 +31,7 @@ public async Task> ErrorsGet([FromQuery] PagingInfo pag return results.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors")] [HttpHead] public async Task ErrorsHead(string status, string modified, string queueAddress) @@ -41,6 +45,7 @@ public async Task ErrorsHead(string status, string modified, string queueAddress Response.WithQueryStatsInfo(queryResult); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpointname}/errors")] [HttpGet] public async Task> ErrorsByEndpointName([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string endpointName) @@ -58,6 +63,7 @@ public async Task> ErrorsByEndpointName([FromQuery] Pag return results.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/summary")] [HttpGet] public async Task> ErrorsSummary() => await store.ErrorsSummary(); diff --git a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs index 437b6fc5a3..bb419a014c 100644 --- a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.MessageFailures.Api { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; @@ -8,6 +10,7 @@ [Route("api")] public class GetErrorByIdController(IErrorMessageDataStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorBy(string failedMessageId) @@ -17,6 +20,7 @@ public async Task> ErrorBy(string failedMessageId) return result == null ? NotFound() : result; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/last/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorLastBy(string failedMessageId) diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 611ee01e5c..1ad75d3bee 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -5,7 +5,9 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -13,6 +15,7 @@ [Route("api")] public class PendingRetryMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/retry")] [HttpPost] public async Task RetryBy(string[] ids) @@ -28,6 +31,7 @@ public async Task RetryBy(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/queues/retry")] [HttpPost] public async Task RetryBy(PendingRetryRequest request) diff --git a/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs b/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs index 9e01c66e15..44e043426c 100644 --- a/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs +++ b/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class QueueAddressController(IQueueAddressStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorQueuesView)] [Route("errors/queues/addresses")] [HttpGet] public async Task> GetAddresses([FromQuery] PagingInfo pagingInfo) @@ -22,6 +25,7 @@ public async Task> GetAddresses([FromQuery] PagingInfo pagin return result.Results; } + [Authorize(Policy = Permissions.ErrorQueuesView)] [Route("errors/queues/addresses/search/{search}")] [HttpGet] public async Task>> GetAddressesBySearchTerm([FromQuery] PagingInfo pagingInfo, string search = null) diff --git a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs index dc4eddf431..21c05d2cc1 100644 --- a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs @@ -8,7 +8,9 @@ namespace ServiceControl.MessageFailures.Api using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using NServiceBus; @@ -17,6 +19,7 @@ namespace ServiceControl.MessageFailures.Api [Route("api")] public class ResolveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/resolve")] [HttpPatch] public async Task ResolveBy(UniqueMessageIdsModel request) @@ -61,6 +64,7 @@ await session.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/queues/resolve")] [HttpPatch] public async Task ResolveBy(QueueModel queueModel) diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index c486129df9..29fb12966c 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -23,6 +25,7 @@ public class RetryMessagesController( IMessageSession messageSession, ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{failedMessageId:required:minlength(1)}/retry")] [HttpPost] public async Task RetryMessageBy([FromQuery(Name = "instance_id")] string instanceId, string failedMessageId) @@ -49,6 +52,7 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/retry")] [HttpPost] public async Task RetryAllBy(List messageIds) @@ -63,6 +67,7 @@ public async Task RetryAllBy(List messageIds) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/queues/{queueAddress:required:minlength(1)}/retry")] [HttpPost] public async Task RetryAllBy(string queueAddress) @@ -76,6 +81,7 @@ await messageSession.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/retry/all")] [HttpPost] public async Task RetryAll() @@ -85,6 +91,7 @@ public async Task RetryAll() return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{endpointName:required:minlength(1)}/retry/all")] [HttpPost] public async Task RetryAllByEndpoint(string endpointName) diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index 0a0271d9fa..d36940bc99 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -4,7 +4,9 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -12,6 +14,7 @@ [Route("api")] public class UnArchiveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/unarchive")] [HttpPatch] public async Task Unarchive(string[] ids) @@ -28,6 +31,7 @@ public async Task Unarchive(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/{from}...{to}/unarchive")] [HttpPatch] public async Task Unarchive(string from, string to) diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index 4899498683..2eed6206cc 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -7,9 +7,11 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Contracts.MessageRedirects; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using Infrastructure.WebApi; using MessageFailures.InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Infrastructure; @@ -25,6 +27,7 @@ public class MessageRedirectsController( IDomainEvents events) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects")] [HttpPost] public async Task NewRedirects(MessageRedirectRequest request) @@ -93,6 +96,7 @@ await session.SendLocal(new RetryPendingMessages return StatusCode((int)HttpStatusCode.Created, messageRedirect); } + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects/{messageRedirectId:guid}")] [HttpPut] public async Task UpdateRedirect(Guid messageRedirectId, MessageRedirectRequest request) @@ -135,6 +139,7 @@ public async Task UpdateRedirect(Guid messageRedirectId, MessageR return NoContent(); } + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects/{messageRedirectId:guid}")] [HttpDelete] public async Task DeleteRedirect(Guid messageRedirectId) @@ -162,6 +167,7 @@ await events.Raise(new MessageRedirectRemoved return NoContent(); } + [Authorize(Policy = Permissions.ErrorRedirectsView)] [Route("redirect")] [HttpHead] public async Task CountRedirects() @@ -172,6 +178,7 @@ public async Task CountRedirects() Response.WithTotalCount(redirects.Redirects.Count); } + [Authorize(Policy = Permissions.ErrorRedirectsView)] [Route("redirects")] [HttpGet] public async Task> Redirects(string sort, string direction, [FromQuery] PagingInfo pagingInfo) diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 5b64d5a22d..0926075b59 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using CompositeViews.Messages; + using Infrastructure.Auth; using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; @@ -25,10 +26,12 @@ public class EndpointsMonitoringController( IMonitoringDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorHeartbeatsView)] [Route("heartbeats/stats")] [HttpGet] public EndpointMonitoringStats HeartbeatStats() => monitoring.GetStats(); + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpoints")] [HttpGet] public EndpointsView[] Endpoints() => monitoring.GetEndpoints(); @@ -44,6 +47,7 @@ public void GetSupportedOperations() Response.Headers.AccessControlExposeHeaders = "Allow"; } + [Authorize(Policy = Permissions.ErrorEndpointsDelete)] [Route("endpoints/{endpointId}")] [HttpDelete] public async Task DeleteEndpoint(Guid endpointId) @@ -59,6 +63,7 @@ public async Task DeleteEndpoint(Guid endpointId) return NoContent(); } + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpoints/known")] [HttpGet] public async Task> KnownEndpoints([FromQuery] PagingInfo pagingInfo) @@ -70,6 +75,7 @@ public async Task> KnownEndpoints([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.ErrorEndpointsManage)] [Route("endpoints/{endpointId}")] [HttpPatch] public async Task Monitoring(Guid endpointId, [FromBody] EndpointUpdateModel data) diff --git a/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs b/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs index be1807e54c..a1d55138db 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs @@ -4,6 +4,8 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Infrastructure.Auth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; using ServiceBus.Management.Infrastructure.Settings; @@ -25,6 +27,7 @@ public class EndpointsSettingsController( IEndpointSettingsStore dataStore, Settings settings) : ControllerBase { + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpointssettings")] [HttpGet] public async IAsyncEnumerable Endpoints([EnumeratorCancellation] CancellationToken token) @@ -49,6 +52,7 @@ public async IAsyncEnumerable Endpoints([EnumeratorCancellation] C } } + [Authorize(Policy = Permissions.ErrorEndpointsManage)] [Route("endpointssettings/{endpointName?}")] [HttpPatch] public async Task diff --git a/src/ServiceControl/Notifications/Api/NotificationsController.cs b/src/ServiceControl/Notifications/Api/NotificationsController.cs index af5f90cfe0..e42cfa5473 100644 --- a/src/ServiceControl/Notifications/Api/NotificationsController.cs +++ b/src/ServiceControl/Notifications/Api/NotificationsController.cs @@ -4,6 +4,8 @@ using System.Net; using System.Threading.Tasks; using Email; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; using ServiceBus.Management.Infrastructure.Settings; @@ -12,6 +14,7 @@ [Route("api")] public class NotificationsController(IErrorMessageDataStore store, Settings settings, EmailSender emailSender) : ControllerBase { + [Authorize(Policy = Permissions.ErrorNotificationsView)] [Route("notifications/email")] [HttpGet] public async Task GetEmailNotificationsSettings() @@ -22,6 +25,7 @@ public async Task GetEmailNotificationsSettings() return notificationsSettings.Email; } + [Authorize(Policy = Permissions.ErrorNotificationsManage)] [Route("notifications/email/toggle")] [HttpPost] public async Task ToggleEmailNotifications(ToggleEmailNotifications request) @@ -36,6 +40,7 @@ public async Task ToggleEmailNotifications(ToggleEmailNotificatio return Ok(); } + [Authorize(Policy = Permissions.ErrorNotificationsManage)] [Route("notifications/email")] [HttpPost] public async Task UpdateSettings(UpdateEmailNotificationsSettingsRequest request) @@ -60,6 +65,7 @@ public async Task UpdateSettings(UpdateEmailNotificationsSettings return Ok(); } + [Authorize(Policy = Permissions.ErrorNotificationsTest)] [Route("notifications/email/test")] [HttpPost] public async Task SendTestEmail() diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index f40611db85..69c805f199 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class FailureGroupsArchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsArchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/archive")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index b03b2b702d..7a3964f9cb 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; using MessageFailures.Api; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -18,6 +20,7 @@ public class FailureGroupsController( IRetryHistoryDataStore retryStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/classifiers")] [HttpGet] public string[] GetSupportedClassifiers() @@ -32,6 +35,7 @@ public string[] GetSupportedClassifiers() return result; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpPost] public async Task EditComment(string groupId, string comment) @@ -41,6 +45,7 @@ public async Task EditComment(string groupId, string comment) return Accepted(); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpDelete] public async Task DeleteComment(string groupId) @@ -50,6 +55,7 @@ public async Task DeleteComment(string groupId) return Accepted(); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{classifier?}")] [HttpGet] public async Task GetAllGroups(string classifier = "Exception Type and Stack Trace", string classifierFilter = default) @@ -64,6 +70,7 @@ public async Task GetAllGroups(string classifier = "Exception return results; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors")] [HttpGet] public async Task> GetGroupErrors(string groupId, [FromQuery] SortInfo sortInfo, [FromQuery] PagingInfo pagingInfo, string status = default, string modified = default) @@ -75,6 +82,7 @@ public async Task> GetGroupErrors(string groupId, [From } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors")] [HttpHead] public async Task GetGroupErrorsCount(string groupId, string status = default, string modified = default) @@ -84,6 +92,7 @@ public async Task GetGroupErrorsCount(string groupId, string status = default, s Response.WithQueryStatsInfo(results); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/history")] [HttpGet] public async Task GetRetryHistory() @@ -95,6 +104,7 @@ public async Task GetRetryHistory() return retryHistory; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task GetGroup(string groupId, string status = default, string modified = default) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 308d427217..8ef2ec86d2 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -2,6 +2,8 @@ namespace ServiceControl.Recoverability.API { using System; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -10,6 +12,7 @@ namespace ServiceControl.Recoverability.API [Route("api")] public class FailureGroupsRetryController(IMessageSession bus, RetryingManager retryingManager) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsRetry)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/retry")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index 5661c8dd78..37e2f0b6d9 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class FailureGroupsUnarchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsUnarchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/unarchive")] [HttpPost] public async Task UnarchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs index 2e85be691f..9be6bec2b2 100644 --- a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class UnacknowledgedGroupsController(IRetryHistoryDataStore retryStore, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/unacknowledgedgroups/{groupId:required:minlength(1)}")] [HttpDelete] public async Task AcknowledgeOperation(string groupId) diff --git a/src/ServiceControl/SagaAudit/SagasController.cs b/src/ServiceControl/SagaAudit/SagasController.cs index 9b581b9758..d3c082de8f 100644 --- a/src/ServiceControl/SagaAudit/SagasController.cs +++ b/src/ServiceControl/SagaAudit/SagasController.cs @@ -2,7 +2,9 @@ namespace ServiceControl.SagaAudit { using System; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -11,6 +13,7 @@ namespace ServiceControl.SagaAudit [Route("api")] public class SagasController(GetSagaByIdApi getSagaByIdApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorSagasView)] [Route("sagas/{id}")] [HttpGet] public async Task Sagas([FromQuery] PagingInfo pagingInfo, Guid id) From e1082ad1a3779aecac907be40d2048ceedb84dc8 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 4 Jun 2026 14:00:11 +0200 Subject: [PATCH 02/53] 1st pass of registering an authorization provider --- .../Hosting/Commands/RunCommand.cs | 1 + .../Auth/PermissionAuthorizationExtensions.cs | 43 ++++++ .../Auth/PermissionPolicyProvider.cs | 59 ++++++++ .../Auth/PermissionRequirement.cs | 11 ++ .../Auth/PermissionVerbHandler.cs | 44 ++++++ .../Auth/RolePermissions.cs | 127 ++++++++++++++++++ .../Hosting/Commands/RunCommand.cs | 1 + .../Hosting/Commands/RunCommand.cs | 1 + 8 files changed, 287 insertions(+) create mode 100644 src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs create mode 100644 src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs create mode 100644 src/ServiceControl.Hosting/Auth/PermissionRequirement.cs create mode 100644 src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/RolePermissions.cs diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index 22e2fff776..d8229e8774 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs @@ -19,6 +19,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlAudit((_, __) => { diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs new file mode 100644 index 0000000000..07229849eb --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -0,0 +1,43 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +/// +/// Registers the permission-based policy authorization services: a dynamic +/// that resolves [Authorize(Policy = "<permission>")] +/// attributes, and — when OIDC is enabled — the that evaluates them +/// against the user's roles. +/// +/// The provider is registered unconditionally so the policy attributes resolve in every configuration +/// (without it, annotated endpoints fail with "AuthorizationPolicy not found"). When OIDC is disabled the +/// provider returns allow-all policies that carry no requirement, so the verb handler is not registered. +/// Wire this into every instance that hosts annotated controllers (Error, Audit, Monitoring). +/// +/// +public static class PermissionAuthorizationExtensions +{ + public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, bool oidcEnabled) + { + var services = hostBuilder.Services; + + // Ensure the authorization core services and options are present (idempotent). + services.AddAuthorization(); + + // Resolve permission policy names dynamically. Registered last so it supersedes the default + // policy provider registered by AddAuthorization(). When OIDC is disabled it returns allow-all + // policies (no requirement); when enabled it emits a PermissionRequirement for the verb handler. + services.AddSingleton(sp => + new PermissionPolicyProvider(sp.GetRequiredService>(), oidcEnabled)); + + // The role-based handler is only needed when OIDC is enabled — otherwise the provider produces + // no PermissionRequirement for it to evaluate. + if (oidcEnabled) + { + services.AddSingleton(); + } + } +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs new file mode 100644 index 0000000000..1d5a52d4ff --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -0,0 +1,59 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure.Auth; + +/// +/// A dynamic that resolves a verb-level authorization policy +/// for each known permission string (e.g. error:messages:retry). +/// +/// The set of valid policy names is known up front (), so every policy is +/// built once at construction into a . The framework +/// calls on every request to a protected endpoint, so this makes that call +/// an O(1) lookup with no per-request policy allocation. (Authorization policies and requirements are +/// immutable, so the prebuilt instances are safely shared across all requests.) +/// +/// +/// When OIDC is enabled each permission maps to a policy carrying a +/// (evaluated by ). When OIDC is disabled the platform runs +/// unauthenticated, so every permission maps to a shared allow-all policy — no requirement, no handler. +/// Unknown policy names resolve to ; the default and fallback policies are +/// delegated to the configured . +/// +/// +public sealed class PermissionPolicyProvider(IOptions authorizationOptions, bool oidcEnabled) + : IAuthorizationPolicyProvider +{ + // Carries no requirement, so it succeeds without any IAuthorizationHandler being registered. + static readonly AuthorizationPolicy AllowAll = + new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build(); + + readonly FrozenDictionary policies = BuildPolicies(oidcEnabled); + + static FrozenDictionary BuildPolicies(bool oidcEnabled) => + Permissions.All.ToFrozenDictionary( + permission => permission, + permission => oidcEnabled + ? new AuthorizationPolicyBuilder().AddRequirements(new PermissionRequirement(permission)).Build() + : AllowAll, + StringComparer.Ordinal); + + public Task GetPolicyAsync(string policyName) => + Task.FromResult(policies.GetValueOrDefault(policyName)); + + public Task GetDefaultPolicyAsync() + { + var defaultPolicy = authorizationOptions.Value.DefaultPolicy + ?? new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); + return Task.FromResult(defaultPolicy); + } + + public Task GetFallbackPolicyAsync() + => Task.FromResult(authorizationOptions.Value.FallbackPolicy); +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs b/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs new file mode 100644 index 0000000000..77039457b7 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using Microsoft.AspNetCore.Authorization; + +/// +/// An that carries the permission string enforced by a +/// [Authorize(Policy = "<permission>")] attribute (e.g. error:messages:view). +/// Evaluated by . +/// +public sealed record PermissionRequirement(string Permission) : IAuthorizationRequirement; diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs new file mode 100644 index 0000000000..f246234c7b --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -0,0 +1,44 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using ServiceControl.Infrastructure.Auth; + +/// +/// Verb-level authorization handler for . It resolves the user's +/// roles and checks them against the hardcoded policy: the user must hold +/// a role (e.g. reader / writer) that grants the requested permission. +/// +/// Only registered — and only reached — when OIDC is enabled. When it is disabled, +/// returns an allow-all policy that carries no +/// , so this handler is not needed. +/// +/// +public sealed class PermissionVerbHandler : AuthorizationHandler +{ + // TODO: The claim that carries a user's roles is identity-provider specific and must become + // configurable (per-IdP) rather than hardcoded. Roles are expected as a flat, multivalued claim; + // the token handler splits a top-level JSON array into individual claims, so no parsing is needed. + // For Keycloak, add a "User Realm Role" protocol mapper with Multivalued = ON and Token Claim Name + // = "roles" (a dotted name like "realm_access.roles" would nest it into an object instead). + const string RoleClaimType = "roles"; + + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); + + + // TODO: Although plural, likely roles will only contain a single value unless we want to define a role for each instance but likely customers don't care about instances + if (RolePermissions.IsGranted(roles, requirement.Permission)) + { + context.Succeed(requirement); + } + + // Otherwise leave the requirement unmet → the request is denied (403/401). + return Task.CompletedTask; + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs new file mode 100644 index 0000000000..f0ff2028a1 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -0,0 +1,127 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; + +/// +/// Hardcoded role → permission policy. Two roles for now: +/// +/// reader — granted every *:*:view permission (read-only access). +/// writer — granted every permission (*:*:*). +/// +/// The wildcard patterns (* is a colon-segment wildcard) are the source of truth, but they are +/// expanded once at type initialization against into a concrete, +/// immutable of granted permissions per role. As a result both +/// and are O(1) hash lookups with no +/// per-call pattern matching or allocation. +/// +/// TODO: interim hardcoded model — replace with a configurable role/permission mapping (loaded from +/// configuration or the IdP) when more than these two coarse roles are needed. +/// +/// +public static class RolePermissions +{ + /// Read-only role: every *:*:view permission. + public const string Reader = "reader"; + + /// Full-access role: every permission. + public const string Writer = "writer"; + + // Source of truth: the wildcard pattern(s) each role grants. + static readonly Dictionary RolePatterns = new(StringComparer.OrdinalIgnoreCase) + { + [Reader] = ["*:*:view"], + [Writer] = ["*:*:*"], + }; + + // Expanded once against the full permission catalogue: role -> concrete granted permissions. + static readonly FrozenDictionary> PermissionsByRole = Expand(); + + /// + /// Returns if any of the supplied grants the + /// requested . O(1) per role — a frozen-set membership test. + /// + public static bool IsGranted(IEnumerable roles, string permission) + { + foreach (var role in roles) + { + if (PermissionsByRole.TryGetValue(role, out var granted) && granted.Contains(permission)) + { + return true; + } + } + + return false; + } + + /// + /// The complete set of permissions granted to a single role (empty if the role is unknown). + /// O(1) and allocation-free — returns the precomputed frozen set. + /// + public static IReadOnlySet GetPermissions(string role) => + PermissionsByRole.TryGetValue(role, out var granted) ? granted : FrozenSet.Empty; + + /// + /// The union of permissions granted across several . Allocation-free for the + /// common single-role case; only the multi-role union allocates. + /// + public static IReadOnlySet GetPermissions(IEnumerable roles) + { + var list = roles as IReadOnlyList ?? roles.ToList(); + if (list.Count <= 1) + { + return list.Count == 0 ? FrozenSet.Empty : GetPermissions(list[0]); + } + + var union = new HashSet(StringComparer.Ordinal); + foreach (var role in list) + { + if (PermissionsByRole.TryGetValue(role, out var granted)) + { + union.UnionWith(granted); + } + } + + return union; + } + + static FrozenDictionary> Expand() + { + var expanded = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (role, patterns) in RolePatterns) + { + expanded[role] = Permissions.All + .Where(permission => patterns.Any(pattern => Matches(pattern, permission))) + .ToFrozenSet(StringComparer.Ordinal); + } + + return expanded.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + /// Matches a colon-delimited permission against a pattern where * is a segment wildcard. + static bool Matches(string pattern, string permission) + { + var patternSegments = pattern.Split(':'); + var permissionSegments = permission.Split(':'); + + if (patternSegments.Length != permissionSegments.Length) + { + return false; + } + + for (var i = 0; i < patternSegments.Length; i++) + { + if (patternSegments[i] != "*" + && !string.Equals(patternSegments[i], permissionSegments[i], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } +} diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index ca648ac222..da23814397 100644 --- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs @@ -16,6 +16,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index ebc08958cf..1f895ec9d3 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -25,6 +25,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(settings.CorsSettings); From 9cb0f0918f8f5b99ab486448180be4777751f7f6 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 4 Jun 2026 14:45:35 +0200 Subject: [PATCH 03/53] =?UTF-8?q?=E2=9C=A8=20Per-IdP=20roles=20claim=20pat?= =?UTF-8?q?h=20via=20IClaimsTransformation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Authentication.RolesClaim (default "realm_access.roles") and a RolesClaimsTransformation that normalises per-IdP role claim shapes — Keycloak's nested realm_access.roles, flat repeated claims (Entra app roles, Keycloak with a User Realm Role mapper, Cognito groups), and JSON-array-as-string values — into canonical "roles" claims that PermissionVerbHandler reads unchanged. The parsing lives in a pure static RolesClaimExtractor in Infrastructure so it can be unit-tested without an ASP.NET host; the IClaimsTransformation implementation in Hosting is a thin wrapper that adds idempotency via a sentinel claim. Removes the TODO on PermissionVerbHandler that the transformation now resolves. --- .../Auth/HostApplicationBuilderExtensions.cs | 7 + .../Auth/PermissionVerbHandler.cs | 7 +- .../Auth/RolesClaimsTransformation.cs | 47 ++++++ .../Auth/RolesClaimExtractorTests.cs | 124 +++++++++++++++ .../Auth/RolesClaimExtractor.cs | 141 ++++++++++++++++++ .../OpenIdConnectSettings.cs | 20 ++- 6 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index f425e7afb2..3aea1f63dd 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Text.Json; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -99,6 +100,12 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder configure.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build()); + + // Normalise per-IdP role claim shapes (Keycloak's nested realm_access.roles, Entra app + // roles, Cognito groups) into canonical "roles" claims for the verb handler. The source + // path is configurable via Authentication.RolesClaim. + hostBuilder.Services.AddSingleton( + new RolesClaimsTransformation(oidcSettings.RolesClaim)); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index f246234c7b..7155bde555 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -18,11 +18,8 @@ namespace ServiceControl.Hosting.Auth; /// public sealed class PermissionVerbHandler : AuthorizationHandler { - // TODO: The claim that carries a user's roles is identity-provider specific and must become - // configurable (per-IdP) rather than hardcoded. Roles are expected as a flat, multivalued claim; - // the token handler splits a top-level JSON array into individual claims, so no parsing is needed. - // For Keycloak, add a "User Realm Role" protocol mapper with Multivalued = ON and Token Claim Name - // = "roles" (a dotted name like "realm_access.roles" would nest it into an object instead). + // The per-IdP variability of the source claim is absorbed by RolesClaimsTransformation, which + // reads from the path configured in Authentication.RolesClaim and emits canonical "roles" claims. const string RoleClaimType = "roles"; protected override Task HandleRequirementAsync( diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs new file mode 100644 index 0000000000..74a62c4eeb --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -0,0 +1,47 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using ServiceControl.Infrastructure.Auth; + +/// +/// Normalises per-IdP role claim shapes into a flat set of roles claims that +/// can read directly. The source path is configured via +/// Authentication.RolesClaim (default realm_access.roles — the Keycloak out-of-box +/// shape). Flat claim names work too (roles for Keycloak with a "User Realm Role" mapper or +/// Microsoft Entra ID app roles, cognito:groups for AWS Cognito). +/// +/// ASP.NET may invoke multiple times for the same principal; a sentinel +/// claim makes the transformation idempotent and returns the same principal on subsequent calls. +/// +/// +public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation +{ + const string SentinelClaimType = "_roles_transformed"; + const string RoleClaimType = "roles"; + + public Task TransformAsync(ClaimsPrincipal principal) + { + if (principal.Identity?.IsAuthenticated != true || principal.HasClaim(SentinelClaimType, "1")) + { + return Task.FromResult(principal); + } + + var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); + + var claims = new Claim[roles.Count + 1]; + claims[0] = new Claim(SentinelClaimType, "1"); + for (var i = 0; i < roles.Count; i++) + { + claims[i + 1] = new Claim(RoleClaimType, roles[i]); + } + + // Build a new principal so the original (cached) instance is left untouched. + var transformed = new ClaimsPrincipal(principal.Identities.ToArray()); + transformed.AddIdentity(new ClaimsIdentity(claims)); + return Task.FromResult(transformed); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs new file mode 100644 index 0000000000..42f2387515 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs @@ -0,0 +1,124 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class RolesClaimExtractorTests +{ + [Test] + public void Flat_claim_with_repeated_string_values_returns_each_value() + { + var principal = PrincipalWith( + new Claim("roles", "operator"), + new Claim("roles", "viewer")); + + var result = RolesClaimExtractor.Extract(principal, "roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "operator", "viewer" })); + } + + [Test] + public void Flat_claim_serialized_as_json_array_string_is_decoded() + { + var principal = PrincipalWith(new Claim("roles", "[\"admin\",\"writer\"]")); + + var result = RolesClaimExtractor.Extract(principal, "roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "admin", "writer" })); + } + + [Test] + public void Nested_keycloak_path_extracts_realm_access_roles() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"roles\":[\"sc-admin\",\"sc-operator\"]}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "sc-admin", "sc-operator" })); + } + + [Test] + public void Nested_path_with_single_string_value_returns_one_value() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"role\":\"sc-admin\"}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.role"); + + Assert.That(result, Is.EqualTo(new[] { "sc-admin" })); + } + + [Test] + public void Missing_top_level_claim_returns_empty() + { + var principal = PrincipalWith(new Claim("other", "anything")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Missing_nested_property_returns_empty() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"resource_access\":{}}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Malformed_json_in_nested_claim_returns_empty() + { + var principal = PrincipalWith(new Claim("realm_access", "not json")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Empty_or_whitespace_path_returns_empty() + { + var principal = PrincipalWith(new Claim("roles", "viewer")); + + Assert.That(RolesClaimExtractor.Extract(principal, ""), Is.Empty); + Assert.That(RolesClaimExtractor.Extract(principal, " "), Is.Empty); + } + + [Test] + public void Non_string_array_entries_are_skipped() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"roles\":[\"valid\",42,null,\"alsovalid\"]}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "valid", "alsovalid" })); + } + + [Test] + public void Multiple_top_level_claims_with_dotted_path_aggregate_values() + { + var principal = PrincipalWith( + new Claim("resource_access", "{\"client-a\":{\"roles\":[\"role-a\"]}}"), + new Claim("resource_access", "{\"client-a\":{\"roles\":[\"role-b\"]}}")); + + var result = RolesClaimExtractor.Extract(principal, "resource_access.client-a.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "role-a", "role-b" })); + } + + static ClaimsPrincipal PrincipalWith(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "Test")); +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs b/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs new file mode 100644 index 0000000000..6a1c40c917 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs @@ -0,0 +1,141 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; + +/// +/// Reads role values out of a at a configurable path. +/// Supports a flat claim name (roles) or a dotted path into a nested JSON object claim +/// (realm_access.roles). Used by RolesClaimsTransformation to normalize per-IdP token +/// shapes (Keycloak, Microsoft Entra ID, AWS Cognito, etc.) into a canonical set of role values. +/// +public static class RolesClaimExtractor +{ + /// + /// Extracts every role value reachable at on . + /// Returns an empty list when the path is absent or the value cannot be interpreted as a string or + /// array of strings — never throws on malformed input. + /// + public static IReadOnlyList Extract(ClaimsPrincipal principal, string rolesClaimPath) + { + if (principal is null || string.IsNullOrWhiteSpace(rolesClaimPath)) + { + return Array.Empty(); + } + + var segments = rolesClaimPath.Split('.'); + var topClaimType = segments[0]; + var results = new List(); + + foreach (var claim in principal.FindAll(topClaimType)) + { + if (segments.Length == 1) + { + AddFlatClaimValues(results, claim.Value); + } + else + { + AddNestedClaimValues(results, claim.Value, segments); + } + } + + return results; + } + + static void AddFlatClaimValues(List results, string claimValue) + { + // Flat claim values are typically a single role string per claim (the JWT bearer middleware + // explodes a top-level JSON array of strings into one claim per element). The fallback path + // handles the rare case where an IdP serialises the array into a single claim value. + if (LooksLikeJsonArray(claimValue)) + { + if (TryParse(claimValue, out var doc)) + { + using (doc) + { + AppendStringArray(results, doc.RootElement); + } + return; + } + } + + results.Add(claimValue); + } + + static void AddNestedClaimValues(List results, string claimValue, string[] segments) + { + if (!TryParse(claimValue, out var doc)) + { + return; + } + + using (doc) + { + var node = doc.RootElement; + for (var i = 1; i < segments.Length; i++) + { + if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(segments[i], out var next)) + { + return; + } + node = next; + } + + AppendStringOrArray(results, node); + } + } + + static void AppendStringOrArray(List results, JsonElement node) + { + if (node.ValueKind == JsonValueKind.String) + { + var single = node.GetString(); + if (!string.IsNullOrEmpty(single)) + { + results.Add(single); + } + } + else if (node.ValueKind == JsonValueKind.Array) + { + AppendStringArray(results, node); + } + } + + static void AppendStringArray(List results, JsonElement array) + { + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + results.Add(value); + } + } + } + } + + static bool LooksLikeJsonArray(string value) + { + var trimmed = value.AsSpan().TrimStart(); + return trimmed.Length > 0 && trimmed[0] == '['; + } + + static bool TryParse(string value, out JsonDocument document) + { + try + { + document = JsonDocument.Parse(value); + return true; + } + catch (JsonException) + { + document = null!; + return false; + } + } +} diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index efbba73239..23c4f58565 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -32,6 +32,13 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); + // Path within the JWT to the user's role values. May be a flat claim name (e.g. "roles" — the + // shape produced by Keycloak with a "User Realm Role" mapper, by Microsoft Entra ID, or by + // AWS Cognito as "cognito:groups") or a dotted path into a nested object claim (e.g. the + // Keycloak out-of-box shape "realm_access.roles"). The RolesClaimsTransformation reads from + // this path and flattens the values into canonical "roles" claims for the authorization handler. + RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "realm_access.roles"); + // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login if (requireServicePulseSettings) @@ -96,6 +103,15 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public bool RequireHttpsMetadata { get; } + /// + /// Path within the JWT where the user's role values live. Defaults to realm_access.roles + /// to match Keycloak's out-of-box token shape. A flat claim name like roles is used when + /// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role" + /// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates + /// into a nested JSON object claim. + /// + public string RolesClaim { get; } + /// /// Optional override for the authority URL that ServicePulse should use for authentication. /// If not specified, ServicePulse uses the main Authority value. @@ -187,8 +203,8 @@ void LogConfiguration(bool requireServicePulseSettings) var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)"; var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)"; - logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", - Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); + logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", + Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); // Warn about potential misconfigurations var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience); From 5ae3c440e4979e83e39695f68043f51bab77211a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 09:46:35 +0200 Subject: [PATCH 04/53] =?UTF-8?q?=F0=9F=90=9B=20Authorize=20policies=20req?= =?UTF-8?q?uire=20authenticated=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without RequireAuthenticatedUser() in the policy, an unauthenticated request reaches PermissionVerbHandler, finds no roles, and fails the requirement — which ASP.NET classifies as FailedRequirements and turns into a 403 Forbid. The OIDC acceptance tests expect 401 Challenge (Should_reject_requests_without_bearer_token, ..._with_invalid/expired /wrong_audience/wrong_issuer). Adding the auth requirement first ensures the failure is classified as FailedAuthentication when no valid principal is present. --- .../Auth/PermissionPolicyProvider.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs index 1d5a52d4ff..1a95ec2ab8 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -40,7 +40,14 @@ static FrozenDictionary BuildPolicies(bool oidcEnab Permissions.All.ToFrozenDictionary( permission => permission, permission => oidcEnabled - ? new AuthorizationPolicyBuilder().AddRequirements(new PermissionRequirement(permission)).Build() + ? new AuthorizationPolicyBuilder() + // RequireAuthenticatedUser() must come first so an unauthenticated request fails as + // FailedAuthentication (→ 401 challenge) rather than FailedRequirements (→ 403 + // forbid). Without it, PermissionVerbHandler is reached for anonymous callers and a + // missing-roles outcome is classified as a forbidden permission failure. + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(permission)) + .Build() : AllowAll, StringComparer.Ordinal); From 8e06d2a978c7ee59f9e4e2f23731288c244e654c Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 09:47:44 +0200 Subject: [PATCH 05/53] =?UTF-8?q?=F0=9F=90=9B=20Wire=20authorization=20in?= =?UTF-8?q?=20acceptance-test=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Primary, Audit, and Monitoring acceptance-test runners called AddServiceControlAuthentication but not AddServiceControlAuthorization, while production RunCommand wires both. Without the authorization provider, ASP.NET cannot resolve the policy names emitted by the Permissions catalogue and any request to a controller carrying [Authorize(Policy = Permissions.X)] throws "AuthorizationPolicy named '...' was not found" — which breaks the test hosts and times out the audit acceptance tests that exercise authorized endpoints. --- .../TestSupport/ServiceControlComponentRunner.cs | 1 + .../TestSupport/ServiceControlComponentRunner.cs | 1 + .../TestSupport/ServiceControlComponentRunner.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 657a84244d..45c6726718 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -123,6 +123,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControl(settings, configuration); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index efcd99c0f6..6e0d233f12 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -120,6 +120,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 1d233e7cc7..319ea95329 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -100,6 +100,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.Logging.ConfigureLogging(LogLevel.Information); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem From 99923fe2258360733f270908b34d1fdb0ff85c57 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 09:58:03 +0200 Subject: [PATCH 06/53] =?UTF-8?q?=F0=9F=90=9B=20Accept-token=20tests=20mus?= =?UTF-8?q?t=20supply=20a=20role-bearing=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Should_accept_requests_with_valid_bearer_token previously passed in CI by accident: AddServiceControlAuthorization was missing from the test runners, so the policy provider could not resolve the [Authorize] policy name and threw, returning 500 — which the assertion "not 401 and not 403" accepted. With the policy provider now wired up, a valid token without any role claim correctly fails the permission requirement and returns 403. The test must therefore supply the canonical "roles" claim with a value that maps to a role granting the endpoint's permission. "reader" grants every *:*:view permission, which covers the endpoints the three acceptance-test variants exercise (error:messages:view, audit:message:view, monitoring:endpoint:view). --- .../OpenIdConnect/When_authentication_is_enabled.cs | 6 +++++- .../OpenIdConnect/When_authentication_is_enabled.cs | 6 +++++- .../OpenIdConnect/When_authentication_is_enabled.cs | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index e19736ff17..139c840b77 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -124,7 +125,10 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including error:messages:view + // required by /api/errors. Without a role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 9d57a41316..1d44d2e64f 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -92,7 +93,10 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including audit:message:view + // required by /api/messages. Without a role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 4781e5e0fc..da0940e81c 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Monitoring.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -92,7 +93,11 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including + // monitoring:endpoint:view required by /monitored-endpoints. Without a + // role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, From 746cefb4165dbd233c7003ba54960668dd78460c Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 10:01:36 +0200 Subject: [PATCH 07/53] =?UTF-8?q?=E2=9C=85=20Approve=20RolesClaim=20in=20P?= =?UTF-8?q?latformSampleSettings=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PlatformSampleSettings approval tests in SC, Audit, and Monitoring unit-test projects serialise the settings object and diff it against an approved JSON snapshot. The earlier Authentication.RolesClaim addition needs the snapshots updated to include the new property. --- .../APIApprovals.PlatformSampleSettings.approved.txt | 1 + .../SettingsTests.PlatformSampleSettings.approved.txt | 1 + .../APIApprovals.PlatformSampleSettings.approved.txt | 1 + 3 files changed, 3 insertions(+) diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 83897faeba..82cf9de13f 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,7 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index c4cfe5ad09..b29ddc3856 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -12,6 +12,7 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 6873e229b3..5ca8fcdf90 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,7 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null From 387a4167244386fe86c66ef527ddc36f7e31b436 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 5 Jun 2026 10:18:57 +0200 Subject: [PATCH 08/53] Fix rebase issues --- .../ServiceControlComponentRunner.cs | 2 +- .../ServiceControlComponentRunner.cs | 2 +- .../Hosting/Commands/RunCommand.cs | 2 +- .../Auth/HostApplicationBuilderExtensions.cs | 3 +- .../Auth/PermissionAuthorizationExtensions.cs | 17 +++++----- .../Auth/PermissionPolicyProvider.cs | 2 +- .../Auth/PermissionVerbHandler.cs | 10 +++--- .../Auth/RolePermissions.cs | 6 +--- .../OpenIdConnectSettings.cs | 32 ++++++++++--------- .../ServiceControlComponentRunner.cs | 2 +- .../Hosting/Commands/RunCommand.cs | 2 +- .../Hosting/Commands/RunCommand.cs | 2 +- 12 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 45c6726718..c3b87d68fd 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -123,7 +123,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControl(settings, configuration); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 6e0d233f12..53a548f038 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -120,7 +120,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index d8229e8774..2bfdb9c065 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs @@ -19,7 +19,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlAudit((_, __) => { diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index 3aea1f63dd..eba714e32d 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -37,7 +37,8 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder ValidateLifetime = oidcSettings.ValidateLifetime, ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey, ValidAudience = oidcSettings.Audience, - ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew + ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew + RoleClaimType = oidcSettings.RolesClaim }; options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; // Don't map inbound claims to legacy Microsoft claim types diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 07229849eb..ca20392461 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Hosting.Auth; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure; /// /// Registers the permission-based policy authorization services: a dynamic @@ -20,8 +21,13 @@ namespace ServiceControl.Hosting.Auth; /// public static class PermissionAuthorizationExtensions { - public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, bool oidcEnabled) + public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings) { + if (!oidcSettings.RoleBasedAuthorizationEnabled) + { + return; + } + var services = hostBuilder.Services; // Ensure the authorization core services and options are present (idempotent). @@ -31,13 +37,8 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // policy provider registered by AddAuthorization(). When OIDC is disabled it returns allow-all // policies (no requirement); when enabled it emits a PermissionRequirement for the verb handler. services.AddSingleton(sp => - new PermissionPolicyProvider(sp.GetRequiredService>(), oidcEnabled)); + new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings.Enabled)); - // The role-based handler is only needed when OIDC is enabled — otherwise the provider produces - // no PermissionRequirement for it to evaluate. - if (oidcEnabled) - { - services.AddSingleton(); - } + services.AddSingleton(service => new PermissionVerbHandler(oidcSettings.RolesClaim)); } } diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs index 1a95ec2ab8..efd07b37e5 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -52,7 +52,7 @@ static FrozenDictionary BuildPolicies(bool oidcEnab StringComparer.Ordinal); public Task GetPolicyAsync(string policyName) => - Task.FromResult(policies.GetValueOrDefault(policyName)); + Task.FromResult(policies.GetValueOrDefault(policyName)); public Task GetDefaultPolicyAsync() { diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 7155bde555..46167d2484 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -18,9 +18,10 @@ namespace ServiceControl.Hosting.Auth; /// public sealed class PermissionVerbHandler : AuthorizationHandler { - // The per-IdP variability of the source claim is absorbed by RolesClaimsTransformation, which - // reads from the path configured in Authentication.RolesClaim and emits canonical "roles" claims. - const string RoleClaimType = "roles"; + public PermissionVerbHandler(string rolesClaimName) + { + RoleClaimType = rolesClaimName; + } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, @@ -28,7 +29,6 @@ protected override Task HandleRequirementAsync( { var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); - // TODO: Although plural, likely roles will only contain a single value unless we want to define a role for each instance but likely customers don't care about instances if (RolePermissions.IsGranted(roles, requirement.Permission)) { @@ -38,4 +38,6 @@ protected override Task HandleRequirementAsync( // Otherwise leave the requirement unmet → the request is denied (403/401). return Task.CompletedTask; } + + internal string RoleClaimType = "roles"; } diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs index f0ff2028a1..7375919263 100644 --- a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -7,7 +7,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Linq; /// -/// Hardcoded role → permission policy. Two roles for now: +/// Role → permission policy. Two roles: /// /// reader — granted every *:*:view permission (read-only access). /// writer — granted every permission (*:*:*). @@ -17,10 +17,6 @@ namespace ServiceControl.Infrastructure.Auth; /// immutable of granted permissions per role. As a result both /// and are O(1) hash lookups with no /// per-call pattern matching or allocation. -/// -/// TODO: interim hardcoded model — replace with a configurable role/permission mapping (loaded from -/// configuration or the IdP) when more than these two coarse roles are needed. -/// /// public static class RolePermissions { diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 23c4f58565..9c2d74158f 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -32,12 +32,8 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); - // Path within the JWT to the user's role values. May be a flat claim name (e.g. "roles" — the - // shape produced by Keycloak with a "User Realm Role" mapper, by Microsoft Entra ID, or by - // AWS Cognito as "cognito:groups") or a dotted path into a nested object claim (e.g. the - // Keycloak out-of-box shape "realm_access.roles"). The RolesClaimsTransformation reads from - // this path and flattens the values into canonical "roles" claims for the authorization handler. - RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "realm_access.roles"); + RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); + RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login @@ -103,15 +99,6 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public bool RequireHttpsMetadata { get; } - /// - /// Path within the JWT where the user's role values live. Defaults to realm_access.roles - /// to match Keycloak's out-of-box token shape. A flat claim name like roles is used when - /// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role" - /// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates - /// into a nested JSON object claim. - /// - public string RolesClaim { get; } - /// /// Optional override for the authority URL that ServicePulse should use for authentication. /// If not specified, ServicePulse uses the main Authority value. @@ -130,6 +117,21 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public string ServicePulseApiScopes { get; } + /// + /// Path within the JWT where the user's role values live. Defaults to realm_access.roles + /// to match Keycloak's out-of-box token shape. A flat claim name like roles is used when + /// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role" + /// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates + /// into a nested JSON object claim. + /// + public string RolesClaim { get; } + + /// + /// Is RBAC enabled. When false, all authenticated users have access to all methods. When true, + /// role based authorization rules are applied. + /// + public bool RoleBasedAuthorizationEnabled { get; } + void Validate(bool requireServicePulseSettings) { if (Enabled) diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 319ea95329..aaab866c82 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -100,7 +100,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.Logging.ConfigureLogging(LogLevel.Information); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index da23814397..6e197e463a 100644 --- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs @@ -16,7 +16,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index 1f895ec9d3..c25485bf5c 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -25,7 +25,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); - hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings.Enabled); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(settings.CorsSettings); From b29e1b12c7501b2d8de2055762ca6087b75e9b77 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 10:19:00 +0200 Subject: [PATCH 09/53] magic number --- .../Auth/RolesClaimsTransformation.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs index 74a62c4eeb..2f59829edc 100644 --- a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -21,11 +21,15 @@ namespace ServiceControl.Hosting.Auth; public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation { const string SentinelClaimType = "_roles_transformed"; + // The sentinel's value is irrelevant; only the claim's presence matters. A non-empty + // placeholder is required because a Claim value cannot be null. + const string SentinelClaimValue = "1"; const string RoleClaimType = "roles"; public Task TransformAsync(ClaimsPrincipal principal) { - if (principal.Identity?.IsAuthenticated != true || principal.HasClaim(SentinelClaimType, "1")) + var isAuthenticated = principal.Identity?.IsAuthenticated == true; + if (!isAuthenticated || AlreadyTransformed(principal)) { return Task.FromResult(principal); } @@ -33,7 +37,7 @@ public Task TransformAsync(ClaimsPrincipal principal) var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); var claims = new Claim[roles.Count + 1]; - claims[0] = new Claim(SentinelClaimType, "1"); + claims[0] = new Claim(SentinelClaimType, SentinelClaimValue); for (var i = 0; i < roles.Count; i++) { claims[i + 1] = new Claim(RoleClaimType, roles[i]); @@ -44,4 +48,9 @@ public Task TransformAsync(ClaimsPrincipal principal) transformed.AddIdentity(new ClaimsIdentity(claims)); return Task.FromResult(transformed); } + + // True once this transformation has stamped its sentinel claim, keeping TransformAsync + // idempotent across the repeated calls ASP.NET makes for the same principal. + static bool AlreadyTransformed(ClaimsPrincipal principal) => + principal.HasClaim(SentinelClaimType, SentinelClaimValue); } From 41dcf2ad1d64f0fa20a0fadecfa7e6f7552c5749 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 13:59:49 +0200 Subject: [PATCH 10/53] =?UTF-8?q?=F0=9F=90=9B=20Always=20register=20the=20?= =?UTF-8?q?permission=20policy=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recent refactor moved Authentication.RoleBasedAuthorizationEnabled to a separate master switch (default false) and made the entire authorization registration short-circuit when it was off. That left the permission policy provider unregistered in every default deployment, so ASP.NET could not resolve the policy names emitted by [Authorize(Policy = Permissions.X)] attributes — every annotated endpoint returned 500 with "AuthorizationPolicy named '...' was not found", which is what was timing out the audit acceptance tests at 90s each. PermissionPolicyProvider already returns allow-all policies for every known permission when its oidcEnabled flag is false. Registering it unconditionally and passing RoleBasedAuthorizationEnabled to that flag gives the right behaviour in all three combinations: RBAC off → allow-all (controllers reachable, no permission check), RBAC on → require auth + the permission requirement evaluated by PermissionVerbHandler. The handler itself remains gated on RoleBasedAuthorizationEnabled since it has nothing to evaluate when RBAC is off. --- .../Auth/PermissionAuthorizationExtensions.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index ca20392461..008b1f37ea 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -23,22 +23,23 @@ public static class PermissionAuthorizationExtensions { public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings) { - if (!oidcSettings.RoleBasedAuthorizationEnabled) - { - return; - } - var services = hostBuilder.Services; // Ensure the authorization core services and options are present (idempotent). services.AddAuthorization(); - // Resolve permission policy names dynamically. Registered last so it supersedes the default - // policy provider registered by AddAuthorization(). When OIDC is disabled it returns allow-all - // policies (no requirement); when enabled it emits a PermissionRequirement for the verb handler. + // The policy provider is registered UNCONDITIONALLY: every instance hosts controllers with + // [Authorize(Policy = Permissions.X)] attributes, and without a provider that knows those + // policy names ASP.NET throws "AuthorizationPolicy named '...' was not found" → 500 on every + // request to an annotated endpoint. When RBAC is disabled the provider returns allow-all + // policies (no requirement), so anonymous-to-the-policy calls pass through and the verb + // handler is unnecessary. services.AddSingleton(sp => - new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings.Enabled)); + new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings.RoleBasedAuthorizationEnabled)); - services.AddSingleton(service => new PermissionVerbHandler(oidcSettings.RolesClaim)); + if (oidcSettings.RoleBasedAuthorizationEnabled) + { + services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); + } } } From 95c6c63b9f1a9e9f5001bf9c49cd88a45fca49dc Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 13:59:58 +0200 Subject: [PATCH 11/53] =?UTF-8?q?=F0=9F=90=9B=20OIDC=20enabled=20tests=20m?= =?UTF-8?q?ust=20enable=20RBAC=20explicitly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentication.RoleBasedAuthorizationEnabled defaults to false, so without an explicit opt-in the policy provider returns allow-all and unauthenticated requests reach the controller — breaking every Should_reject_requests_* test in the three When_authentication_is_enabled classes. Add WithRoleBasedAuthorizationEnabled() to the test configuration helper and call it alongside WithAuthenticationEnabled() in all three OIDC enabled fixtures. --- .../OpenIdConnect/OpenIdConnectTestConfiguration.cs | 13 +++++++++++++ .../OpenIdConnect/When_authentication_is_enabled.cs | 1 + .../OpenIdConnect/When_authentication_is_enabled.cs | 1 + .../OpenIdConnect/When_authentication_is_enabled.cs | 1 + 4 files changed, 16 insertions(+) diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs index dc056b05c3..3148b18275 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs @@ -34,6 +34,18 @@ public OpenIdConnectTestConfiguration WithAuthenticationDisabled() return this; } + /// + /// Enables role-based authorization. When on, controllers carrying + /// [Authorize(Policy = Permissions.X)] require the caller's "roles" claim to map to a + /// role that grants the permission via RolePermissions. When off, the policy provider + /// returns allow-all policies and any authenticated request reaches the controller. + /// + public OpenIdConnectTestConfiguration WithRoleBasedAuthorizationEnabled() + { + SetEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true"); + return this; + } + /// /// Disables settings validation. This allows testing with placeholder/fake OIDC settings. /// Should only be used in test scenarios where a real OIDC provider is not available. @@ -164,6 +176,7 @@ public void ClearConfiguration() ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY"); + ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED"); ClearEnvironmentVariable("VALIDATECONFIG"); } diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 139c840b77..425b8b6e92 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -36,6 +36,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithServicePulseClientId(TestClientId) diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 1d44d2e64f..1891b545d3 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -33,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index da0940e81c..0f94dba925 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -33,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); From 89977de60c3b72b18b2f784cebd823cab38c58e0 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 14:00:09 +0200 Subject: [PATCH 12/53] =?UTF-8?q?=E2=9C=85=20Refresh=20PlatformSampleSetti?= =?UTF-8?q?ngs=20snapshots=20for=20RBAC=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two settings shifted in the OpenIdConnectSettings serialisation: RolesClaim moved after the ServicePulse block and its default changed from "realm_access.roles" to "roles", and the new RoleBasedAuthorizationEnabled property (default false) was added. Sync the three approved snapshots so the approval tests reflect the current shape. --- .../APIApprovals.PlatformSampleSettings.approved.txt | 5 +++-- .../SettingsTests.PlatformSampleSettings.approved.txt | 5 +++-- .../APIApprovals.PlatformSampleSettings.approved.txt | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 82cf9de13f..c73fd28a46 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,10 +12,11 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, - "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index b29ddc3856..fed5a2acf4 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -12,10 +12,11 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, - "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 5ca8fcdf90..72e33e6946 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,10 +12,11 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, - "RolesClaim": "realm_access.roles", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, From af98ef4daea0dff7cbe8b9c756e3fefec9fc238f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 5 Jun 2026 10:19:00 +0200 Subject: [PATCH 13/53] magic number --- .../Auth/RolesClaimsTransformation.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs index 74a62c4eeb..2f59829edc 100644 --- a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -21,11 +21,15 @@ namespace ServiceControl.Hosting.Auth; public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation { const string SentinelClaimType = "_roles_transformed"; + // The sentinel's value is irrelevant; only the claim's presence matters. A non-empty + // placeholder is required because a Claim value cannot be null. + const string SentinelClaimValue = "1"; const string RoleClaimType = "roles"; public Task TransformAsync(ClaimsPrincipal principal) { - if (principal.Identity?.IsAuthenticated != true || principal.HasClaim(SentinelClaimType, "1")) + var isAuthenticated = principal.Identity?.IsAuthenticated == true; + if (!isAuthenticated || AlreadyTransformed(principal)) { return Task.FromResult(principal); } @@ -33,7 +37,7 @@ public Task TransformAsync(ClaimsPrincipal principal) var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); var claims = new Claim[roles.Count + 1]; - claims[0] = new Claim(SentinelClaimType, "1"); + claims[0] = new Claim(SentinelClaimType, SentinelClaimValue); for (var i = 0; i < roles.Count; i++) { claims[i + 1] = new Claim(RoleClaimType, roles[i]); @@ -44,4 +48,9 @@ public Task TransformAsync(ClaimsPrincipal principal) transformed.AddIdentity(new ClaimsIdentity(claims)); return Task.FromResult(transformed); } + + // True once this transformation has stamped its sentinel claim, keeping TransformAsync + // idempotent across the repeated calls ASP.NET makes for the same principal. + static bool AlreadyTransformed(ClaimsPrincipal principal) => + principal.HasClaim(SentinelClaimType, SentinelClaimValue); } From 1811c14fa2a925f762995867c69f7dfe3fd3aec3 Mon Sep 17 00:00:00 2001 From: williambza Date: Mon, 8 Jun 2026 11:49:21 +0200 Subject: [PATCH 14/53] Make role based always on, but only filter if auth enabled --- .../Auth/PermissionAuthorizationExtensions.cs | 7 ++----- .../Auth/PermissionPolicyProvider.cs | 9 +++++---- src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 008b1f37ea..82d6c3fcbc 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -35,11 +35,8 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // policies (no requirement), so anonymous-to-the-policy calls pass through and the verb // handler is unnecessary. services.AddSingleton(sp => - new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings.RoleBasedAuthorizationEnabled)); + new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings)); - if (oidcSettings.RoleBasedAuthorizationEnabled) - { - services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); - } + services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); } } diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs index efd07b37e5..500e50c335 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Hosting.Auth; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// @@ -27,19 +28,19 @@ namespace ServiceControl.Hosting.Auth; /// delegated to the configured . /// /// -public sealed class PermissionPolicyProvider(IOptions authorizationOptions, bool oidcEnabled) +public sealed class PermissionPolicyProvider(IOptions authorizationOptions, OpenIdConnectSettings oidcSettings) : IAuthorizationPolicyProvider { // Carries no requirement, so it succeeds without any IAuthorizationHandler being registered. static readonly AuthorizationPolicy AllowAll = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build(); - readonly FrozenDictionary policies = BuildPolicies(oidcEnabled); + readonly FrozenDictionary policies = BuildPolicies(oidcSettings); - static FrozenDictionary BuildPolicies(bool oidcEnabled) => + static FrozenDictionary BuildPolicies(OpenIdConnectSettings oidcSettings) => Permissions.All.ToFrozenDictionary( permission => permission, - permission => oidcEnabled + permission => oidcSettings.RoleBasedAuthorizationEnabled ? new AuthorizationPolicyBuilder() // RequireAuthenticatedUser() must come first so an unauthenticated request fails as // FailedAuthentication (→ 401 challenge) rather than FailedRequirements (→ 403 diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 46167d2484..68a56e29ba 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -29,7 +29,6 @@ protected override Task HandleRequirementAsync( { var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); - // TODO: Although plural, likely roles will only contain a single value unless we want to define a role for each instance but likely customers don't care about instances if (RolePermissions.IsGranted(roles, requirement.Permission)) { context.Succeed(requirement); @@ -40,4 +39,4 @@ protected override Task HandleRequirementAsync( } internal string RoleClaimType = "roles"; -} +} \ No newline at end of file From a28df24d865357884d10343eb4e5d96b8c0d8b9f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 10:34:21 +0200 Subject: [PATCH 15/53] Audit log for authorization decisions (#5520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Audit log for every authorization decision PermissionVerbHandler now resolves the calling principal's subject id (sub claim), display name (preferred_username, falling back to name then sub), and roles, and emits a structured "allow" or "deny" entry through the new IAuthorizationAuditLog for every verb-level check. Both outcomes are captured — denies alone are insufficient for most compliance use cases — and the reason embeds the matched role(s) for allow and the candidate role(s) for deny. AuthorizationAuditLog writes on the stable category "ServiceControl.Audit" via a source-generated structured log method so any ILogger-compatible sink (Seq, OTLP, file, in-memory test double, …) can collect or filter the trail without coupling to the concrete type name. The audit log is registered alongside the verb handler — only when OIDC is enabled and the handler has decisions to make. Unauthenticated requests are skipped at the top of HandleRequirementAsync so the audit log only records identified principals; the framework challenges with 401 via the policy's RequireAuthenticatedUser anyway. Ported from the keycloak-rbac-poc spike (with the namespace flattened from Infrastructure.Auth.Rbac to Infrastructure.Auth to match the real branch) along with a RecordingLoggerProvider test helper colocated with the unit tests. * ✨ Configurable required subject claims for the audit log PermissionVerbHandler now reads the subject id and subject name from configurable claim keys (Authentication.SubjectIdClaim, default "sub"; Authentication.SubjectNameClaim, default "preferred_username") and throws InvalidOperationException when an authenticated principal lacks either claim or carries an empty value — both are required for the audit log to be meaningful and a missing value indicates an IdP misconfiguration the operator needs to fix. The settings are passed through AddServiceControlAuthorization, which now takes the full OpenIdConnectSettings (the existing bool-only overload is removed; the six callers — three RunCommand entry points and three acceptance-test runners — pass the settings object). The MockOidcServer test helper defaults preferred_username to the subject value so existing OIDC acceptance tests don't have to repeat the boilerplate. * Separate allow/deny log templates, log deny as Warning * Add TODO for instance-specific audit categories in AuthorizationAuditLog * IDE0055 * Refactor dependencies on settings to inject `OpenIdConnectSettings` * ✨ Route ServiceControl.Audit category to a structured JSON log target Add a dedicated NLog target and a final logging rule that emit the authorization audit trail as structured JSON on the ServiceControl.Audit category, separate from the plain-text operational log, so it can be shipped to a SIEM without the two streams polluting each other. Audit events are captured from Info upward (allow = Information, deny = Warning) independent of the operational LogLevel, so lowering operational verbosity never drops entries from the audit trail. The audit rule is registered before the catch-all operational rules and marked Final so audit events are not duplicated into the operational log. Extracts BuildConfiguration from ConfigureNLog, registers the targets explicitly so the configuration is fully formed before it is installed, and exposes AuthorizationAuditLog.AuditCategory so the routing is unit-testable against a single source of truth for the category name. Tests assert the routing structure and that a real decision renders as one valid JSON object per line. * 🐛 Align AuthorizationAuditLog tests with the Allow:/Deny: templates The audit log message templates were changed to a capitalised "Allow:"/"Deny:" prefix (and deny moved to Warning) when the allow/deny templates were split, but these tests still asserted the old lowercase "allow"/"deny" substrings and an Information level for deny — so they failed on CI (Linux-Default, Windows-Default). Update the assertions to match the current output: "Allow:"/"Deny:" and deny at Warning level. * ✨ Emit the authorization audit event as an Elastic Common Schema (ECS) document AuthorizationAuditLog now serialises each decision as an ECS-shaped JSON document (@timestamp, event.kind/category/type/action/outcome, user.id/name, and the app-specific servicecontrol.* namespace) so it ingests into Elastic/Kibana — and most SIEMs — with no custom mapping. The schema is owned in the domain class; the NLog audit target now writes the pre-rendered document verbatim (one object per line) instead of assembling JSON in logging configuration. Allow/deny is carried by event.type (["allowed"]/["denied"]) and event.outcome (success/failure); the log level still differs (Information/Warning) so sinks can alert on denies without parsing the payload. Relaxed JSON escaping keeps the output readable for log sinks. Only fields available at the verb-level check are populated today; user.roles, user.email and resource scope follow as that data reaches the decision point. * Remove outdated TODO in `AuthorizationAuditLog` comment regarding instance-specific categories * Apply suggestions from code review Co-authored-by: WilliamBZA * cleanup --------- Co-authored-by: WilliamBZA --- .../OpenIdConnect/MockOidcServer.cs | 4 + ...rovals.PlatformSampleSettings.approved.txt | 2 + .../Auth/HostApplicationBuilderExtensions.cs | 11 +- .../Auth/PermissionAuthorizationExtensions.cs | 19 ++- .../Auth/PermissionVerbHandler.cs | 68 +++++++++-- .../Auth/RolesClaimsTransformation.cs | 8 +- .../Auth/AuthorizationAuditLogTests.cs | 98 ++++++++++++++++ .../Auth/RecordingLoggerProvider.cs | 59 ++++++++++ .../LoggingConfiguratorTests.cs | 108 ++++++++++++++++++ .../Auth/AuthorizationAuditLog.cs | 83 ++++++++++++++ .../Auth/IAuthorizationAuditLog.cs | 25 ++++ .../LoggingConfigurator.cs | 62 +++++++++- .../OpenIdConnectSettings.cs | 25 +++- ...sTests.PlatformSampleSettings.approved.txt | 2 + ...rovals.PlatformSampleSettings.approved.txt | 2 + 15 files changed, 547 insertions(+), 29 deletions(-) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs index 03f5c156eb..81322cf47c 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs @@ -184,9 +184,13 @@ public string GenerateToken( { var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); + // sub + preferred_username are required by PermissionVerbHandler for the audit log; + // defaulting them here keeps callers concise. Callers that need to test the + // missing-claim path pass an explicit additionalClaim with an empty value to override. var claims = new List { new(JwtRegisteredClaimNames.Sub, subject), + new("preferred_username", subject), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index c73fd28a46..4a1b91b263 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index eba714e32d..08c4db3cf3 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using ServiceControl.Infrastructure; @@ -21,6 +22,11 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder return; } + // Shared with the authorization services and the claims transformation below; registered + // once so it can be constructor-injected rather than captured. TryAdd keeps it idempotent + // with AddServiceControlAuthorization, which registers the same instance. + hostBuilder.Services.TryAddSingleton(oidcSettings); + _ = hostBuilder.Services.AddAuthentication(options => { options.DefaultScheme = "Bearer"; @@ -104,9 +110,8 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder // Normalise per-IdP role claim shapes (Keycloak's nested realm_access.roles, Entra app // roles, Cognito groups) into canonical "roles" claims for the verb handler. The source - // path is configurable via Authentication.RolesClaim. - hostBuilder.Services.AddSingleton( - new RolesClaimsTransformation(oidcSettings.RolesClaim)); + // path is configurable via Authentication.RolesClaim, read off the injected settings. + hostBuilder.Services.AddSingleton(); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 82d6c3fcbc..6e56aa6e89 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -3,9 +3,10 @@ namespace ServiceControl.Hosting.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; /// /// Registers the permission-based policy authorization services: a dynamic @@ -25,6 +26,10 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h { var services = hostBuilder.Services; + // The settings are shared by every auth service below (and the authentication wiring), so they + // are registered once in DI and constructor-injected rather than captured in factory lambdas. + services.TryAddSingleton(oidcSettings); + // Ensure the authorization core services and options are present (idempotent). services.AddAuthorization(); @@ -34,9 +39,15 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // request to an annotated endpoint. When RBAC is disabled the provider returns allow-all // policies (no requirement), so anonymous-to-the-policy calls pass through and the verb // handler is unnecessary. - services.AddSingleton(sp => - new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings)); + services.AddSingleton(); - services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); + // The provider only emits a PermissionRequirement when RBAC is enabled, so the handler is the + // only thing that evaluates one. It is registered alongside the provider (cheap singleton, never + // invoked when no requirement is produced). The handler emits an audit-log entry for every + // decision through IAuthorizationAuditLog so the platform can show, after the fact, who attempted + // what and how the system responded. The subject-id and subject-name claim names are read off the + // injected OpenIdConnectSettings so the handler can match them on the principal. + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 68a56e29ba..6d3036f59b 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -1,42 +1,86 @@ #nullable enable namespace ServiceControl.Hosting.Auth; +using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// /// Verb-level authorization handler for . It resolves the user's /// roles and checks them against the hardcoded policy: the user must hold -/// a role (e.g. reader / writer) that grants the requested permission. +/// a role (e.g. reader / writer) that grants the requested permission. Every decision is +/// captured through for compliance. /// /// Only registered — and only reached — when OIDC is enabled. When it is disabled, /// returns an allow-all policy that carries no /// , so this handler is not needed. /// /// -public sealed class PermissionVerbHandler : AuthorizationHandler +public sealed class PermissionVerbHandler( + IAuthorizationAuditLog auditLog, + OpenIdConnectSettings oidcSettings) + : AuthorizationHandler { - public PermissionVerbHandler(string rolesClaimName) - { - RoleClaimType = rolesClaimName; - } - protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { - var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); + // Unauthenticated requests have no subject and no roles. The framework will challenge with + // 401 because the policy also includes RequireAuthenticatedUser; skipping here keeps the + // audit log restricted to identified principals. + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + var subjectId = RequireClaim(context.User, oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); + var subjectName = RequireClaim(context.User, oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); + var roles = context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToArray(); + var permission = requirement.Permission; - if (RolePermissions.IsGranted(roles, requirement.Permission)) + if (RolePermissions.IsGranted(roles, permission)) { + auditLog.Decision( + subjectId, + subjectName, + permission, + resource: null, + allowed: true, + reason: roles.Length == 0 + ? $"User holds '{permission}'" + : $"User holds '{permission}' via role(s) [{string.Join(", ", roles)}]"); + context.Succeed(requirement); + return Task.CompletedTask; } - // Otherwise leave the requirement unmet → the request is denied (403/401). + auditLog.Decision( + subjectId, + subjectName, + permission, + resource: null, + allowed: false, + reason: roles.Length == 0 + ? $"User has no roles granting '{permission}'" + : $"None of the user's role(s) [{string.Join(", ", roles)}] grants '{permission}'"); + + // Leave the requirement unmet → the framework forbids (403). return Task.CompletedTask; } - internal string RoleClaimType = "roles"; -} \ No newline at end of file + static string RequireClaim(ClaimsPrincipal user, string claimType, string settingName) + { + var value = user.FindFirst(claimType)?.Value; + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException( + $"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " + + "Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits."); + } + return value; + } +} diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs index 2f59829edc..f2641c80a1 100644 --- a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Hosting.Auth; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; +using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// @@ -18,13 +19,12 @@ namespace ServiceControl.Hosting.Auth; /// claim makes the transformation idempotent and returns the same principal on subsequent calls. /// /// -public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation +public sealed class RolesClaimsTransformation(OpenIdConnectSettings oidcSettings) : IClaimsTransformation { const string SentinelClaimType = "_roles_transformed"; // The sentinel's value is irrelevant; only the claim's presence matters. A non-empty // placeholder is required because a Claim value cannot be null. const string SentinelClaimValue = "1"; - const string RoleClaimType = "roles"; public Task TransformAsync(ClaimsPrincipal principal) { @@ -34,13 +34,13 @@ public Task TransformAsync(ClaimsPrincipal principal) return Task.FromResult(principal); } - var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); + var roles = RolesClaimExtractor.Extract(principal, oidcSettings.RolesClaim); var claims = new Claim[roles.Count + 1]; claims[0] = new Claim(SentinelClaimType, SentinelClaimValue); for (var i = 0; i < roles.Count; i++) { - claims[i + 1] = new Claim(RoleClaimType, roles[i]); + claims[i + 1] = new Claim(ClaimTypes.Role, roles[i]); } // Build a new principal so the original (cached) instance is left untouched. diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs new file mode 100644 index 0000000000..69213d9887 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs @@ -0,0 +1,98 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class AuthorizationAuditLogTests +{ + [Test] + public void Decision_allow_emits_one_entry_on_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice-sub-001", "Alice Smith", "error:messages:retry", "acme.sales", allowed: true, reason: "role:reader matched"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success")); + Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub-001")); + Assert.That(ecs.GetProperty("user").GetProperty("name").GetString(), Is.EqualTo("Alice Smith")); + Assert.That(ecs.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:messages:retry")); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information)); + } + + [Test] + public void Decision_deny_emits_one_entry_on_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("bob-sub-002", "Bob Jones", "error:messages:retry", null, allowed: false, reason: "no matching role"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + var ecs = JsonDocument.Parse(entries[0].Message).RootElement; + 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(entries[0].Level, Is.EqualTo(LogLevel.Warning)); + } + + [Test] + public void Decision_does_not_appear_on_other_categories() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("carol-sub-003", "Carol White", "error:endpoints:view", null, allowed: true, reason: "role:reader matched"); + + Assert.That(provider.EntriesFor("ServiceControl.SomeOtherCategory"), Is.Empty); + } + + [Test] + public void Multiple_decisions_accumulate_in_order() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice-sub-001", "alice", "error:messages:view", null, allowed: true, "role matched"); + auditLog.Decision("alice-sub-001", "alice", "error:messages:retry", "acme.finance", allowed: false, "out of scope"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(2)); + Assert.That(JsonDocument.Parse(entries[0].Message).RootElement.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(JsonDocument.Parse(entries[1].Message).RootElement.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied")); + } + + [TestCase(null, "Alice", "error:messages:retry", "reason")] + [TestCase("", "Alice", "error:messages:retry", "reason")] + [TestCase("alice-sub-001", null, "error:messages:retry", "reason")] + [TestCase("alice-sub-001", "", "error:messages:retry", "reason")] + [TestCase("alice-sub-001", "Alice", null, "reason")] + [TestCase("alice-sub-001", "Alice", "", "reason")] + [TestCase("alice-sub-001", "Alice", "error:messages:retry", null)] + [TestCase("alice-sub-001", "Alice", "error:messages:retry", "")] + public void Decision_throws_when_required_argument_is_null_or_empty(string? subjectId, string? subjectName, string? permission, string? reason) + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + Assert.That( + () => auditLog.Decision(subjectId!, subjectName!, permission!, resource: null, allowed: true, reason: reason!), + Throws.InstanceOf()); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs new file mode 100644 index 0000000000..a753a2b8e4 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RecordingLoggerProvider.cs @@ -0,0 +1,59 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +/// +/// In-memory that captures log entries for test assertions. +/// Thread-safe. Use for all captured entries; +/// to filter by category. +/// +sealed class RecordingLoggerProvider : ILoggerProvider +{ + readonly ConcurrentQueue entries = new(); + + public IReadOnlyList Entries => entries.ToArray(); + + public IReadOnlyList EntriesFor(string category) => + entries.Where(e => e.Category == category).ToArray(); + + public ILogger CreateLogger(string categoryName) => + new RecordingLogger(categoryName, entries); + + public void Dispose() { } +} + +sealed record LogEntry( + string Category, + LogLevel Level, + EventId EventId, + string Message, + Exception? Exception); + +sealed class RecordingLogger(string category, ConcurrentQueue sink) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + sink.Enqueue(new LogEntry(category, logLevel, eventId, message, exception)); + } + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs new file mode 100644 index 0000000000..cbc790d9de --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs @@ -0,0 +1,108 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests; + +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Config; +using NLog.Extensions.Logging; +using NLog.Targets; +using NUnit.Framework; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; +using LogLevel = NLog.LogLevel; + +[TestFixture] +public class LoggingConfiguratorTests +{ + static readonly string AuditPattern = $"{AuthorizationAuditLog.AuditCategory}*"; + + static LoggingConfiguration BuildConfig() => + LoggingConfigurator.BuildConfiguration("logfile.txt", Path.GetTempPath(), LogLevel.Info); + + [Test] + public void Audit_target_emits_the_prerendered_event_verbatim() + { + var auditTarget = BuildConfig().LoggingRules + .Single(r => r.LoggerNamePattern == AuditPattern) + .Targets.OfType() + .Single(t => t.Name == "audit-console"); + + var rendered = auditTarget.Layout.Render(new LogEventInfo(LogLevel.Info, AuthorizationAuditLog.AuditCategory, "ECS-PAYLOAD")); + + Assert.That(rendered, Is.EqualTo("ECS-PAYLOAD"), + "the audit target must pass the pre-rendered ECS JSON through unwrapped, not double-encode it"); + } + + [Test] + public void Audit_events_do_not_fall_through_to_the_operational_log() + { + var config = BuildConfig(); + + var auditRule = config.LoggingRules.Single(r => r.LoggerNamePattern == AuditPattern); + var operationalConsoleRule = config.LoggingRules.Single(r => r.LoggerNamePattern == "*" && r.Targets.Any(t => t.Name == "console")); + + Assert.That(auditRule.Final, Is.True, "the audit rule must be final so audit JSON is not duplicated into the plain-text operational log"); + Assert.That( + config.LoggingRules.IndexOf(auditRule), + Is.LessThan(config.LoggingRules.IndexOf(operationalConsoleRule)), + "the audit rule must be evaluated before the catch-all console rule for Final to take effect"); + } + + [Test] + public void Audit_decisions_render_as_valid_structured_json() + { + // Use the exact JSON layout the production configuration builds... + var auditLayout = BuildConfig().AllTargets + .OfType() + .Single(t => t.Name == "audit-console") + .Layout; + + // ...and capture what it renders, driven through the real audit logger over an isolated NLog factory. + var captured = new MemoryTarget("audit-capture") { Layout = auditLayout }; + var captureConfig = new LoggingConfiguration(); + captureConfig.AddRule(LogLevel.Info, LogLevel.Fatal, captured, AuditPattern); + var logFactory = new LogFactory { Configuration = captureConfig }; + + using (var loggerFactory = LoggerFactory.Create(b => b.AddNLog(_ => logFactory))) + { + var audit = new AuthorizationAuditLog(loggerFactory); + audit.Decision("alice-sub-001", "Alice Smith", "error:messages:retry", "acme.sales", allowed: true, reason: "role:sc-operator matched"); + audit.Decision("bob-sub-002", "Bob Jones", "error:messages:retry", null, allowed: false, reason: "no matching role"); + } + + logFactory.Flush(); + + Assert.That(captured.Logs, Has.Count.EqualTo(2), "expected one JSON line per decision"); + + foreach (var line in captured.Logs) + { + TestContext.Progress.WriteLine(line); + } + + var allow = JsonDocument.Parse(captured.Logs[0]).RootElement; + Assert.Multiple(() => + { + Assert.That(allow.GetProperty("@timestamp").GetString(), Is.Not.Empty, "ECS @timestamp should be present"); + Assert.That(allow.GetProperty("event").GetProperty("kind").GetString(), Is.EqualTo("event")); + Assert.That(allow.GetProperty("event").GetProperty("category")[0].GetString(), Is.EqualTo("iam")); + Assert.That(allow.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("allowed")); + Assert.That(allow.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:messages:retry")); + Assert.That(allow.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success")); + Assert.That(allow.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub-001")); + Assert.That(allow.GetProperty("user").GetProperty("name").GetString(), Is.EqualTo("Alice Smith")); + Assert.That(allow.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("acme.sales")); + }); + + var deny = JsonDocument.Parse(captured.Logs[1]).RootElement; + Assert.Multiple(() => + { + 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"); + }); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs new file mode 100644 index 0000000000..75a15fb980 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -0,0 +1,83 @@ +#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; + +/// +/// 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 const string AuditCategory = "ServiceControl.Audit"; // Logger name is used in logging configuration to write audit entries to a separate file. + + 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 }; + + public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) + { + ArgumentException.ThrowIfNullOrEmpty(subjectId); + ArgumentException.ThrowIfNullOrEmpty(subjectName); + ArgumentException.ThrowIfNullOrEmpty(permission); + ArgumentException.ThrowIfNullOrEmpty(reason); + + var auditEvent = BuildEcsEvent(subjectId, subjectName, permission, resource, allowed, reason); + + if (allowed) + { + LogAllow(logger, auditEvent); + } + else + { + LogDeny(logger, auditEvent); + } + } + + // Serialises one authorization decision as an Elastic Common Schema (ECS) document so it ingests into + // Elastic/Kibana — and most SIEMs — with no custom mapping. The schema is owned here, in the domain, + // rather than in logging configuration. event.type/outcome carry the allow/deny; servicecontrol.* is the + // app-specific namespace ECS reserves for custom fields. + static string BuildEcsEvent(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason) + { + var ecs = new Dictionary + { + ["@timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["event"] = new + { + kind = "event", + category = new[] { "iam" }, + type = new[] { allowed ? "allowed" : "denied" }, + action = permission, + outcome = allowed ? "success" : "failure" + }, + ["user"] = new + { + id = subjectId, + name = subjectName + }, + ["servicecontrol"] = new + { + permission, + resource, + reason + } + }; + + 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); +} diff --git a/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs new file mode 100644 index 0000000000..d6449673cc --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/IAuthorizationAuditLog.cs @@ -0,0 +1,25 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// +/// Records every authorization allow/deny decision so the platform can demonstrate, after the fact, +/// who attempted what and how the system responded. Both allow and deny outcomes are captured — +/// denies alone are insufficient for most compliance use cases. +/// +/// Implementations write structured log entries on a stable category so sinks (Seq, OTLP, file, +/// in-memory test double, …) can filter on it without coupling to the concrete type name. +/// +/// +public interface IAuthorizationAuditLog +{ + /// + /// Records a single authorization decision. + /// + /// Stable identifier of the principal (e.g. the JWT sub claim). Must not be null or empty. + /// Human-readable display name of the principal (e.g. preferred_username). Must not be null or empty. + /// The permission that was evaluated (e.g. error:messages:retry). + /// The specific resource checked, or for verb-level checks. + /// if the decision was allow; for deny. + /// A human-readable explanation (e.g. which role granted the permission, or why nothing matched). + void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason); +} diff --git a/src/ServiceControl.Infrastructure/LoggingConfigurator.cs b/src/ServiceControl.Infrastructure/LoggingConfigurator.cs index b94fd8b296..6c0b090dea 100644 --- a/src/ServiceControl.Infrastructure/LoggingConfigurator.cs +++ b/src/ServiceControl.Infrastructure/LoggingConfigurator.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Infrastructure using NLog.Layouts; using NLog.Targets; using ServiceControl.Configuration; + using ServiceControl.Infrastructure.Auth; using LogManager = NServiceBus.Logging.LogManager; using LogLevel = NLog.LogLevel; @@ -28,6 +29,17 @@ public static void ConfigureLogging(LoggingSettings loggingSettings) } public static string ConfigureNLog(string logFileName, string logPath, LogLevel logLevel) + { + var nlogConfig = BuildConfiguration(logFileName, logPath, logLevel); + + NLog.LogManager.Configuration = nlogConfig; + + var logEventInfo = new LogEventInfo { TimeStamp = DateTime.UtcNow }; + var fileTarget = nlogConfig.FindTargetByName("file"); + return AppEnvironment.RunningInContainer ? "console" : fileTarget.FileName.Render(logEventInfo); + } + + public static LoggingConfiguration BuildConfiguration(string logFileName, string logPath, LogLevel logLevel) { //configure NLog var nlogConfig = new LoggingConfiguration(); @@ -65,20 +77,62 @@ public static string ConfigureNLog(string logFileName, string logPath, LogLevel FinalMinLevel = LogLevel.Warn }; + // The authorization audit trail is emitted on a dedicated category, separate from the plain-text + // operational log, so it can be shipped to a SIEM without the two streams polluting each other. + // Each event is already a complete ECS JSON document (built in AuthorizationAuditLog); the target + // writes it verbatim, one object per line. + var auditLayout = new SimpleLayout("${message}"); + + var auditConsoleTarget = new ConsoleTarget + { + Name = "audit-console", + Layout = auditLayout + }; + + var auditFileTarget = new FileTarget + { + Name = "audit-file", + ArchiveEvery = FileArchivePeriod.Day, + FileName = Path.Combine(logPath, "audit.json"), + ArchiveSuffixFormat = ".{1:yyyy-MM-dd}.{0:00}", + Layout = auditLayout, + MaxArchiveFiles = 14, + ArchiveAboveSize = 30 * megaByte + }; + + // Audit events are captured from Info upward (allow = Information, deny = Warning) regardless of the + // operational LogLevel — lowering the operational verbosity must never drop entries from the audit trail. + // Final stops audit events from also reaching the catch-all operational rules below, so this rule must + // be registered before them. + var auditRule = new LoggingRule + { + LoggerNamePattern = $"{AuthorizationAuditLog.AuditCategory}*", + Final = true + }; + auditRule.SetLoggingLevels(LogLevel.Info, LogLevel.Fatal); + auditRule.Targets.Add(auditConsoleTarget); + if (!AppEnvironment.RunningInContainer) + { + auditRule.Targets.Add(auditFileTarget); + } + + nlogConfig.AddTarget(consoleTarget); + nlogConfig.AddTarget(auditConsoleTarget); + nlogConfig.LoggingRules.Add(aspNetCoreRule); nlogConfig.LoggingRules.Add(httpClientRule); + nlogConfig.LoggingRules.Add(auditRule); nlogConfig.LoggingRules.Add(new LoggingRule("*", logLevel, consoleTarget)); if (!AppEnvironment.RunningInContainer) { + nlogConfig.AddTarget(fileTarget); + nlogConfig.AddTarget(auditFileTarget); nlogConfig.LoggingRules.Add(new LoggingRule("*", logLevel, fileTarget)); } - NLog.LogManager.Configuration = nlogConfig; - - var logEventInfo = new LogEventInfo { TimeStamp = DateTime.UtcNow }; - return AppEnvironment.RunningInContainer ? "console" : fileTarget.FileName.Render(logEventInfo); + return nlogConfig; } static LogLevel ToNLogLevel(this Microsoft.Extensions.Logging.LogLevel level) => diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index 9c2d74158f..33f0e461a0 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -35,6 +35,13 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); + // Claims that identify the principal in the authorization audit log. The handler treats both + // as required — a missing or empty value is a sign that the IdP isn't emitting the expected + // claim and the operator needs to fix the configuration, so the handler will throw rather + // than substitute a placeholder. + SubjectIdClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectIdClaim", "sub"); + SubjectNameClaim = SettingsReader.Read(rootNamespace, "Authentication.SubjectNameClaim", "preferred_username"); + // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login if (requireServicePulseSettings) @@ -99,6 +106,20 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public bool RequireHttpsMetadata { get; } + /// + /// Claim that carries the stable subject identifier (e.g. the JWT sub claim) recorded in + /// the authorization audit log. Required — the handler throws if the configured claim is absent + /// or empty on an authenticated principal. + /// + public string SubjectIdClaim { get; } + + /// + /// Claim that carries the human-readable subject name (e.g. preferred_username) recorded + /// in the authorization audit log. Required — the handler throws if the configured claim is + /// absent or empty on an authenticated principal. + /// + public string SubjectNameClaim { get; } + /// /// Optional override for the authority URL that ServicePulse should use for authentication. /// If not specified, ServicePulse uses the main Authority value. @@ -205,8 +226,8 @@ void LogConfiguration(bool requireServicePulseSettings) var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)"; var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)"; - logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", - Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); + logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, SubjectIdClaim={SubjectIdClaim}, SubjectNameClaim={SubjectNameClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", + Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, SubjectIdClaim, SubjectNameClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); // Warn about potential misconfigurations var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience); diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index fed5a2acf4..a35b4112e2 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 72e33e6946..6e7be8475f 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,6 +12,8 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, "ServicePulseApiScopes": null, From febce883e9989185a1f5bcf3ad75b58dda7f2ec4 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 22 Jun 2026 10:56:08 +0200 Subject: [PATCH 16/53] Add service.name/version to OTLP log resource (#5536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Attribute exported OTLP logs to the ServiceControl instance The OTLP log exporter was registered with a bare AddOtlpExporter() and no OpenTelemetry resource, so exported log records had no service identity and showed up as "unknown_service" in the backend. LoggerUtil.Initialize(serviceName, serviceVersion) is now called once at process startup (in each instance's Program.cs, before any logger is created) and builds a single OpenTelemetry resource — service.name = instance name, service.version = instance version, plus an auto-generated service.instance.id. Both the host logging pipeline and the static bootstrap loggers (CreateStaticLogger) share that resource, so every exported record — including early startup diagnostics — is attributed to the instance. service.name matches the value used by the Audit metrics resource, so logs and metrics correlate. The resource still uses ResourceBuilder.CreateDefault(), so operators can enrich it with deployment attributes via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES. * Refactor LoggerUtil to auto-resolve service identity at startup * As resource builder is now created based only on info from entry assembly that can be handled fully private * 🐛 Fix IDE0055 formatting: remove double space in CreateResourcesBuilder signature --- .../LoggerUtil.cs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Infrastructure/LoggerUtil.cs b/src/ServiceControl.Infrastructure/LoggerUtil.cs index b00617fbe0..a562e6f934 100644 --- a/src/ServiceControl.Infrastructure/LoggerUtil.cs +++ b/src/ServiceControl.Infrastructure/LoggerUtil.cs @@ -2,10 +2,13 @@ { using System; using System.Collections.Concurrent; + using System.Diagnostics; + using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NLog.Extensions.Logging; using OpenTelemetry.Logs; + using OpenTelemetry.Resources; using ServiceControl.Infrastructure.TestLogger; [Flags] @@ -24,11 +27,32 @@ public static class LoggerUtil public static string SeqAddress { private get; set; } - public static bool IsLoggingTo(Loggers logger) + // Telemetry resource attached to exported OTLP logs (service.name/service.version/service.instance.id). + // Set once at process startup via Initialize() — before any logger is created — so both the host pipeline + // and the static bootstrap loggers (CreateStaticLogger) share a single instance identity. Defaults to + // CreateDefault() (which still honors OTEL_SERVICE_NAME/OTEL_RESOURCE_ATTRIBUTES) for the rare logger + // created before Initialize runs. + static ResourceBuilder serviceResourceBuilder = CreateResourcesBuilder(); + + static ResourceBuilder CreateResourcesBuilder() { - return (logger & ActiveLoggers) == logger; + var asm = Assembly.GetEntryAssembly() ?? throw new InvalidOperationException("Entry assembly not found"); + var serviceName = asm.GetName().Name ?? throw new InvalidOperationException("Entry assembly name not found"); + var serviceVersion = FileVersionInfo.GetVersionInfo(asm.Location).ProductVersion; + + // CreateDefault() also reads OTEL_SERVICE_NAME/OTEL_RESOURCE_ATTRIBUTES, so operators can still enrich + // the resource with deployment-specific attributes via those environment variables. + return ResourceBuilder + .CreateDefault() + .AddService( + serviceName, + serviceVersion: serviceVersion, + autoGenerateServiceInstanceId: true + ); } + public static bool IsLoggingTo(Loggers logger) => (logger & ActiveLoggers) == logger; + public static void ConfigureLogging(this ILoggingBuilder loggingBuilder, LogLevel level) { loggingBuilder.SetMinimumLevel(level); @@ -54,7 +78,11 @@ public static void ConfigureLogging(this ILoggingBuilder loggingBuilder, LogLeve } if (IsLoggingTo(Loggers.Otlp)) { - loggingBuilder.AddOpenTelemetry(configure => configure.AddOtlpExporter()); + loggingBuilder.AddOpenTelemetry(configure => + { + configure.SetResourceBuilder(serviceResourceBuilder); + configure.AddOtlpExporter(); + }); } } From 3825fc29b6fc7e14d9ef475d8578a71e4badb0e0 Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Wed, 1 Jul 2026 10:26:00 +0200 Subject: [PATCH 17/53] Expose a per-instance my/routes authorization manifest (#5538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add permissions endpoint * Add endpoints to show user permissions * Fix approvals * Show username instead of ID * Use IReadOnlySet instead of List * 🐛 Update HttpApiRoutes approval for GetAllMyPermissions rename * 🐛 Deserialize permissions response into a concrete HashSet in OIDC tests The /api/my/permissions/all endpoint serializes PermissionsDescriptor whose Permissions property is IReadOnlySet. System.Text.Json cannot deserialize into an interface, so the acceptance tests that read the response back threw NotSupportedException. Introduce a shared PermissionsResponse DTO with a concrete HashSet and use it from both When_my_permissions_are_requested and When_role_based_authorization_is_disabled. The production API contract (IReadOnlySet) is unchanged. * ✨ Replace my/permissions with a my/routes allowed-route manifest (#5556) Instead of leaking internal permission strings to ServicePulse, expose the concrete set of API routes the caller is allowed to reach, so the UI gates on a stable route contract rather than coupling to the permission catalogue. - Project the ASP.NET EndpointDataSource into a route⇒permission table and resolve per-request effective permissions, filtering the manifest to the routes the caller can reach. - Add the my/routes controller (served by every instance), route template normalization, and the manifest DTOs. - Remove the my/permissions endpoint, MeController, PermissionsResponse and the root-doc permission fields. - Add an admin role (read-all + manage config/admin-area resources, no message-triage write actions), sitting between reader and writer. - Acceptance, infrastructure and approval tests for the new surface, plus an anti-drift policy guard. * Add my/routes to RootController --------- Co-authored-by: Ramon Smits --- .../When_my_routes_are_requested.cs | 110 ++++++++++++++++++ src/ServiceControl.Api/Contracts/RootUrls.cs | 1 + .../API/APIApprovals.cs | 46 +++++++- .../APIApprovals.HttpApiRoutes.approved.txt | 1 + .../HostApplicationBuilderExtensions.cs | 1 + .../Auth/ClaimsPrinicpalExtensionMethods.cs | 20 ++++ .../Auth/MyRoutesController.cs | 29 +++++ .../Auth/PermissionAuthorizationExtensions.cs | 4 + .../Auth/PermissionVerbHandler.cs | 18 +-- .../Auth/RouteAuthorizationTable.cs | 57 +++++++++ .../Auth/EffectivePermissionsTests.cs | 46 ++++++++ .../Auth/RouteManifestFilterTests.cs | 39 +++++++ .../Auth/RouteTemplateNormalizerTests.cs | 21 ++++ .../Auth/EffectivePermissions.cs | 26 +++++ .../Auth/RolePermissions.cs | 16 +++ .../Auth/RouteManifest.cs | 51 ++++++++ .../API/APIApprovals.cs | 46 +++++++- .../APIApprovals.HttpApiRoutes.approved.txt | 1 + .../HostApplicationBuilderExtensions.cs | 1 + .../API/APIApprovals.cs | 46 +++++++- .../APIApprovals.HttpApiRoutes.approved.txt | 1 + .../APIApprovals.RootPathValue.approved.txt | 3 +- .../Infrastructure/Api/ConfigurationApi.cs | 1 + .../HostApplicationBuilderExtensions.cs | 1 + 24 files changed, 567 insertions(+), 19 deletions(-) create mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs create mode 100644 src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs create mode 100644 src/ServiceControl.Hosting/Auth/MyRoutesController.cs create mode 100644 src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/RouteManifest.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs new file mode 100644 index 0000000000..2093227e72 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -0,0 +1,110 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect; + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using AcceptanceTesting.OpenIdConnect; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +/// +/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries. +/// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it +/// already calls rather than on the server's internal permission vocabulary. +/// +class When_my_routes_are_requested : AcceptanceTest +{ + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + const string TestAudience = "api://test-audience"; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + [Test] + public async Task Should_reject_requests_without_bearer_token() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, HttpMethod.Get, "/api/my/routes"); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertUnauthorized(response); + } + + [Test] + public async Task Reader_can_view_but_cannot_retry() + { + var routes = await GetRoutes(RolePermissions.Reader); + + using (Assert.EnterMultipleScope()) + { + Assert.That(routes.Any(r => r.UrlTemplate == "/api/configuration"), Is.True, + "reader holds :view permissions, so view routes are allowed"); + Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.False, + "reader has no retry permission, so retry routes are excluded"); + } + } + + [Test] + public async Task Writer_can_retry() + { + var routes = await GetRoutes(RolePermissions.Writer); + + Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.True, + "writer holds every permission, so retry routes are allowed"); + } + + async Task> GetRoutes(string role) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, HttpMethod.Get, "/api/my/routes", token); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertAuthenticated(response); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize>(content, SerializerOptions); + } + + class Context : ScenarioContext; +} diff --git a/src/ServiceControl.Api/Contracts/RootUrls.cs b/src/ServiceControl.Api/Contracts/RootUrls.cs index f019ef249e..2e9a2aeb32 100644 --- a/src/ServiceControl.Api/Contracts/RootUrls.cs +++ b/src/ServiceControl.Api/Contracts/RootUrls.cs @@ -20,5 +20,6 @@ public class RootUrls public string EventLogItems { get; set; } public string ArchivedGroupsUrl { get; set; } public string GetArchiveGroup { get; set; } + public string MyRoutesUrl { get; set; } } } diff --git a/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs b/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs index 60663693db..78e39fe0eb 100644 --- a/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs @@ -7,6 +7,7 @@ using System.Text; using Audit.Infrastructure.Settings; using Audit.Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -14,6 +15,8 @@ using Microsoft.AspNetCore.Routing; using NUnit.Framework; using Particular.Approvals; + using ServiceControl.Hosting.Auth; + using ServiceControl.Infrastructure.Auth; [TestFixture] class APIApprovals @@ -84,7 +87,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -101,6 +106,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index c9303ead9c..bfcfbc190b 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -13,4 +13,5 @@ GET /messages/{id}/body => ServiceControl.Audit.Auditing.MessagesView.GetMessage GET /messages/search => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q, CancellationToken cancellationToken) GET /messages/search/{keyword} => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword, CancellationToken cancellationToken) GET /messages2 => ServiceControl.Audit.Auditing.MessagesView.GetMessages2Controller:GetAllMessages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q, CancellationToken cancellationToken) +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() GET /sagas/{id} => ServiceControl.Audit.SagaAudit.SagasController:Sagas(PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 638041d4b1..cb70f9aedf 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -24,6 +24,7 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde options.ModelBinderProviders.Insert(0, new SortInfoModelBindingProvider()); }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); } } diff --git a/src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs b/src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs new file mode 100644 index 0000000000..0bb4a2ed3f --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Hosting.Auth +{ + using System; + using System.Security.Claims; + + public static class ClaimsPrinicpalExtensionMethods + { + public static string RequireClaim(this ClaimsPrincipal user, string claimType, string settingName) + { + var value = user.FindFirst(claimType)?.Value; + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException( + $"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " + + "Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits."); + } + return value; + } + } +} \ No newline at end of file diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs new file mode 100644 index 0000000000..28a6d36ec1 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -0,0 +1,29 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +/// +/// Returns the API routes the current token may call, as { method, urlTemplate } entries. +/// This is the per-instance authorization contract for clients (ServicePulse): each instance reports +/// only the routes it serves, so a client matches its outgoing request against the allowed set without +/// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that +/// contract, so it is reachable by any authenticated user ([Authorize], no specific permission). +/// +[ApiController] +[Route("api")] +[Authorize] +public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConnectSettings settings) : ControllerBase +{ + [HttpGet] + [Route("my/routes")] + public ActionResult> GetMyRoutes() + { + var effective = EffectivePermissions.ForUser(User, settings); + return Ok(RouteManifestFilter.Filter(table.Entries, effective)); + } +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 6e56aa6e89..534ac5d95b 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -49,5 +49,9 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // injected OpenIdConnectSettings so the handler can match them on the principal. 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.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 6d3036f59b..138827331f 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -37,8 +37,8 @@ protected override Task HandleRequirementAsync( return Task.CompletedTask; } - var subjectId = RequireClaim(context.User, oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); - var subjectName = RequireClaim(context.User, oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); + var subjectId = context.User.RequireClaim(oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); + var subjectName = context.User.RequireClaim(oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); var roles = context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToArray(); var permission = requirement.Permission; @@ -71,16 +71,4 @@ protected override Task HandleRequirementAsync( // Leave the requirement unmet → the framework forbids (403). return Task.CompletedTask; } - - static string RequireClaim(ClaimsPrincipal user, string claimType, string settingName) - { - var value = user.FindFirst(claimType)?.Value; - if (string.IsNullOrEmpty(value)) - { - throw new InvalidOperationException( - $"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " + - "Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits."); - } - return value; - } -} +} \ No newline at end of file diff --git a/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs b/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs new file mode 100644 index 0000000000..fc376d39d4 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs @@ -0,0 +1,57 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using ServiceControl.Infrastructure.Auth; + +/// +/// Projects the wired controller endpoints into the static route ⇒ permission table that backs +/// the my/routes manifest. Built once on first access (after endpoints are mapped) and cached +/// for the process lifetime — routes are compiled in and never change at runtime. Each endpoint +/// contributes one per HTTP method, carrying the policy name from its +/// [Authorize(Policy = …)] attribute (the permission), whether it is [AllowAnonymous], +/// and the normalized template. No-policy endpoints are authenticated-only, matching the +/// RequireAuthenticatedUser fallback policy. +/// +public sealed class RouteAuthorizationTable(EndpointDataSource endpointDataSource) +{ + readonly Lazy> entries = new(() => Build(endpointDataSource)); + + public IReadOnlyList Entries => entries.Value; + + static IReadOnlyList Build(EndpointDataSource endpointDataSource) + { + var result = new List(); + + foreach (var endpoint in endpointDataSource.Endpoints.OfType()) + { + // Only controller actions: skips the SignalR hub and other non-MVC endpoints. + if (endpoint.Metadata.GetMetadata() is null) + { + continue; + } + + var template = RouteTemplateNormalizer.Normalize(endpoint.RoutePattern.RawText ?? string.Empty); + var allowAnonymous = endpoint.Metadata.GetMetadata() is not null; + var requiredPermission = endpoint.Metadata + .GetOrderedMetadata() + .Select(authorize => authorize.Policy) + .FirstOrDefault(policy => !string.IsNullOrEmpty(policy)); + + var methods = endpoint.Metadata.GetMetadata()?.HttpMethods + ?? []; + + foreach (var method in methods) + { + result.Add(new RouteAuthInfo(method, template, requiredPermission, allowAnonymous)); + } + } + + return result; + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs new file mode 100644 index 0000000000..c4383e8dcb --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs @@ -0,0 +1,46 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Linq; +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class EffectivePermissionsTests +{ + static readonly SettingsRootNamespace TestNamespace = new("ServiceControl"); + + static ClaimsPrincipal PrincipalWithRoles(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "test")); + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null); + } + + [Test] + public void Rbac_enabled_returns_the_union_of_role_permissions() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true"); + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); + + var result = EffectivePermissions.ForUser(PrincipalWithRoles(RolePermissions.Reader), settings); + + Assert.That(result, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader))); + } + + [Test] + public void Rbac_disabled_returns_all_permissions() + { + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); + + var result = EffectivePermissions.ForUser(PrincipalWithRoles("anything"), settings); + + Assert.That(result, Is.EquivalentTo(Permissions.All)); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs new file mode 100644 index 0000000000..64323629ef --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs @@ -0,0 +1,39 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Collections.Generic; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteManifestFilterTests +{ + static readonly RouteAuthInfo Retry = new("POST", "/api/errors/{id}/retry", "error:messages:retry", false); + static readonly RouteAuthInfo Archive = new("POST", "/api/errors/{id}/archive", "error:messages:archive", false); + static readonly RouteAuthInfo Configuration = new("GET", "/api/configuration", null, false); + static readonly RouteAuthInfo Root = new("GET", "/api", null, true); + + [Test] + public void Includes_granted_permissioned_anonymous_and_authenticated_only_routes() + { + var routes = new[] { Retry, Archive, Configuration, Root }; + var effective = new HashSet { "error:messages:retry" }; + + var result = RouteManifestFilter.Filter(routes, effective); + + Assert.That(result, Is.EquivalentTo(new[] + { + new RouteManifestEntry("POST", "/api/errors/{id}/retry"), + new RouteManifestEntry("GET", "/api/configuration"), + new RouteManifestEntry("GET", "/api"), + })); + } + + [Test] + public void Excludes_permissioned_routes_not_in_the_effective_set() + { + var result = RouteManifestFilter.Filter(new[] { Archive }, new HashSet()); + + Assert.That(result, Is.Empty); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs new file mode 100644 index 0000000000..373bd4337d --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteTemplateNormalizerTests +{ + [TestCase("api/errors/{failedMessageId:required:minlength(1)}/retry", "/api/errors/{failedMessageId}/retry")] + [TestCase("api/configuration", "/api/configuration")] + [TestCase("api/customchecks/{id}", "/api/customchecks/{id}")] + [TestCase("api/errors/groups/{classifier?}", "/api/errors/groups/{classifier}")] + [TestCase("api/messages/{*catchAll}", "/api/messages/{catchAll}")] + [TestCase("api/my/routes", "/api/my/routes")] + [TestCase("/api/already/rooted", "/api/already/rooted")] + public void Strips_constraints_and_roots_the_template(string raw, string expected) + { + Assert.That(RouteTemplateNormalizer.Normalize(raw), Is.EqualTo(expected)); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs new file mode 100644 index 0000000000..1cff8bd5e6 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs @@ -0,0 +1,26 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +/// +/// The set of permissions a principal effectively holds, computed per request. Mirrors the inputs the +/// enforcement handler uses: when role-based authorization is enabled, the union of the permissions +/// granted by the principal's claims (via ); +/// when it is disabled the platform runs allow-all, so every known permission is held. +/// +public static class EffectivePermissions +{ + public static IReadOnlySet ForUser(ClaimsPrincipal user, OpenIdConnectSettings settings) + { + if (!settings.RoleBasedAuthorizationEnabled) + { + return Permissions.All; + } + + var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value); + return RolePermissions.GetPermissions(roles); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs index 7375919263..bb79f98f02 100644 --- a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -26,11 +26,27 @@ public static class RolePermissions /// Full-access role: every permission. public const string Writer = "writer"; + /// + /// Platform-administrator role: read-only on everything, plus full management of the configuration / + /// admin-area resources (licensing, notifications, retry redirects, throughput, connections) — but + /// not the message-triage write actions (retry/edit/archive/restore). + /// + public const string Admin = "admin"; + // Source of truth: the wildcard pattern(s) each role grants. static readonly Dictionary RolePatterns = new(StringComparer.OrdinalIgnoreCase) { [Reader] = ["*:*:view"], [Writer] = ["*:*:*"], + [Admin] = + [ + "*:*:view", + "error:licensing:*", + "error:notifications:*", + "error:redirects:*", + "error:throughput:*", + "error:connections:*", + ], }; // Expanded once against the full permission catalogue: role -> concrete granted permissions. diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs new file mode 100644 index 0000000000..c697a056f2 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -0,0 +1,51 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +/// +/// Normalizes an ASP.NET route pattern's raw text into the template form ServicePulse matches its +/// outgoing requests against: inline constraints/defaults/optional markers and catch-all stars are +/// removed (parameter names kept), and a single leading slash is guaranteed. For example +/// api/errors/{id:required:minlength(1)}/retry/api/errors/{id}/retry. +/// +public static partial class RouteTemplateNormalizer +{ + public static string Normalize(string rawTemplate) + { + var stripped = ParameterToken().Replace(rawTemplate, "{${name}}"); + return stripped.StartsWith('/') ? stripped : "/" + stripped; + } + + // Matches a single route parameter token: optional catch-all star(s), the parameter name, then + // anything up to the closing brace (constraints, default value, optional marker). + [GeneratedRegex(@"\{\*{0,2}(?[A-Za-z0-9_]+)[^}]*\}")] + private static partial Regex ParameterToken(); +} + +/// A route the server hosts, with the authorization metadata read from its endpoint. +public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous); + +/// A single allowed-route entry returned to the client. +public sealed record RouteManifestEntry(string Method, string UrlTemplate); + +/// +/// Projects the route table down to the entries a caller may invoke. A route is included when it is +/// anonymous, requires only authentication (no specific permission), or its required permission is in +/// the caller's effective set. Enforcement and this projection read the same inputs, so the advertised +/// manifest cannot drift from what the server actually allows. +/// +public static class RouteManifestFilter +{ + public static IReadOnlyList Filter( + IEnumerable routes, + IReadOnlySet effectivePermissions) => + routes + .Where(route => route.AllowAnonymous + || route.RequiredPermission is null + || effectivePermissions.Contains(route.RequiredPermission)) + .Select(route => new RouteManifestEntry(route.Method, route.UrlTemplate)) + .ToList(); +} diff --git a/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs b/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs index 19dd4ce5f5..bcab962001 100644 --- a/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs @@ -5,10 +5,13 @@ namespace ServiceControl.Monitoring.UnitTests.API; using System.Linq; using System.Reflection; using System.Text; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NUnit.Framework; using Particular.Approvals; +using ServiceControl.Hosting.Auth; +using ServiceControl.Infrastructure.Auth; [TestFixture] public class APIApprovals @@ -62,7 +65,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -79,6 +84,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index c744e9bc77..3ce592eb39 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -6,3 +6,4 @@ GET /monitored-endpoints => ServiceControl.Monitoring.Http.Diagrams.DiagramApiCo GET /monitored-endpoints/{endpointName} => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:GetSingleEndpointMetrics(String endpointName, Nullable history) GET /monitored-endpoints/disconnected => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:DisconnectedEndpointCount() DELETE /monitored-instance/{endpointName}/{instanceId} => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:DeleteEndpointInstance(String endpointName, String instanceId) +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() diff --git a/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 37399a80fd..7ae51ad477 100644 --- a/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -17,6 +17,7 @@ public static void AddServiceControlMonitoringApi(this IHostApplicationBuilder h options.Filters.Add(); }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); } } \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/API/APIApprovals.cs b/src/ServiceControl.UnitTests/API/APIApprovals.cs index 1a75ce026f..04d17fc409 100644 --- a/src/ServiceControl.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.UnitTests/API/APIApprovals.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using Api.Contracts; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -18,7 +19,9 @@ using Particular.Approvals; using Particular.ServiceControl.Licensing; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Hosting.Auth; using ServiceControl.Infrastructure.Api; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.WebApi; using ServiceControl.Monitoring.HeartbeatMonitoring; @@ -94,7 +97,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -111,6 +116,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 9b088fad11..5a0176d2f5 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -47,6 +47,7 @@ GET /messages/{id}/body => ServiceControl.CompositeViews.Messages.GetMessagesCon GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q) GET /messages/search/{keyword} => ServiceControl.CompositeViews.Messages.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword) GET /messages2 => ServiceControl.CompositeViews.Messages.GetMessages2Controller:Messages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q) +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() GET /notifications/email => ServiceControl.Notifications.Api.NotificationsController:GetEmailNotificationsSettings() POST /notifications/email => ServiceControl.Notifications.Api.NotificationsController:UpdateSettings(UpdateEmailNotificationsSettingsRequest request) POST /notifications/email/test => ServiceControl.Notifications.Api.NotificationsController:SendTestEmail() diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt index c211707591..b73f751bba 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt @@ -16,5 +16,6 @@ "SagasUrl": "http://localhost/sagas", "EventLogItems": "http://localhost/eventlogitems", "ArchivedGroupsUrl": "http://localhost/errors/groups/{classifier?}", - "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}" + "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}", + "MyRoutesUrl": "http://localhost/my/routes" } \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs index 607013d677..a15cca6853 100644 --- a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs +++ b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs @@ -43,6 +43,7 @@ public Task GetUrls(string baseUrl, CancellationToken cancellationToke EventLogItems = baseUrl + "eventlogitems", ArchivedGroupsUrl = baseUrl + "errors/groups/{classifier?}", GetArchiveGroup = baseUrl + "archive/groups/id/{groupId}", + MyRoutesUrl = baseUrl + "my/routes", }; return Task.FromResult(model); diff --git a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 62eb5bdb21..aee6e80584 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -36,6 +36,7 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); controllers.AddApplicationPart(typeof(LicensingController).Assembly); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); var signalR = builder.Services.AddSignalR(); From 92db56cceef3688460430c01023f33de66f43558 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 1 Jul 2026 17:03:52 +0200 Subject: [PATCH 18/53] =?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 19/53] =?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 20/53] =?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 21/53] =?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 22/53] =?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 23/53] =?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 24/53] =?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 25/53] =?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 26/53] =?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 27/53] =?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 28/53] =?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 29/53] =?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 30/53] =?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 000f47e2a1b4afc6a44ec4c9ade2cc825a1df7ca Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 2 Jul 2026 17:03:04 +0200 Subject: [PATCH 31/53] Replace wildcard role-permission expansion with explicit lists (#5571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ Replace wildcard role-permission expansion with explicit lists Roles are explicit permission-constant lists built from four additive groups: Read (16 views), ReadConfiguration (licensing/notifications/ redirects/throughput views), Operate (message triage, housekeeping deletes, endpoints/connections manage), Configure (licensing/ notifications/redirects/throughput manage + test). Reader = Read + ReadConfiguration, Writer = Read + Operate, Admin = everything. All pattern parsing/expansion machinery is removed. The sets match the include/exclude patterns of #5569 exactly: writer holds endpoints:manage and connections:manage but has no access to the licensing/notifications/redirects/throughput areas (not even :view), so reader is intentionally not a subset of writer. Guard tests break the build when a new permission constant is not assigned to a role, enforce reader/writer ⊂ admin, and pin writer's configuration-area exclusions. * minimize diff * Remove unneeded tests that test which permissions certain role have. --- .../When_authentication_is_enabled.cs | 2 +- .../When_my_routes_are_requested.cs | 43 ----- .../When_authentication_is_enabled.cs | 2 +- .../Auth/EffectivePermissionsTests.cs | 46 ----- .../Auth/RolePermissionsTests.cs | 22 +++ .../Auth/EffectivePermissions.cs | 16 +- .../Auth/Permissions.cs | 2 +- .../Auth/RolePermissions.cs | 177 +++++++----------- .../When_authentication_is_enabled.cs | 2 +- 9 files changed, 102 insertions(+), 210 deletions(-) delete mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RolePermissionsTests.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 658db5cade..b88df37305 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -126,7 +126,7 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - // The "reader" role grants every *:*:view permission, including error:messages:view + // The "reader" role grants every :view permission, including error:messages:view // required by /api/errors. Without a role-bearing claim the request would be 403. var validToken = mockOidcServer.GenerateToken( additionalClaims: new[] { new Claim("roles", "reader") }); diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs index 2093227e72..97888c183c 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -63,48 +63,5 @@ public async Task Should_reject_requests_without_bearer_token() OpenIdConnectAssertions.AssertUnauthorized(response); } - [Test] - public async Task Reader_can_view_but_cannot_retry() - { - var routes = await GetRoutes(RolePermissions.Reader); - - using (Assert.EnterMultipleScope()) - { - Assert.That(routes.Any(r => r.UrlTemplate == "/api/configuration"), Is.True, - "reader holds :view permissions, so view routes are allowed"); - Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.False, - "reader has no retry permission, so retry routes are excluded"); - } - } - - [Test] - public async Task Writer_can_retry() - { - var routes = await GetRoutes(RolePermissions.Writer); - - Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.True, - "writer holds every permission, so retry routes are allowed"); - } - - async Task> GetRoutes(string role) - { - HttpResponseMessage response = null; - - _ = await Define() - .Done(async ctx => - { - var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]); - response = await OpenIdConnectAssertions.SendRequestWithBearerToken( - HttpClient, HttpMethod.Get, "/api/my/routes", token); - return response != null; - }) - .Run(); - - OpenIdConnectAssertions.AssertAuthenticated(response); - - var content = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize>(content, SerializerOptions); - } - class Context : ScenarioContext; } diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index ab5053a505..5926e71c41 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -94,7 +94,7 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - // The "reader" role grants every *:*:view permission, including audit:message:view + // The "reader" role grants every :view permission, including audit:message:view // required by /api/messages. Without a role-bearing claim the request would be 403. var validToken = mockOidcServer.GenerateToken( additionalClaims: new[] { new Claim("roles", "reader") }); diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs deleted file mode 100644 index c4383e8dcb..0000000000 --- a/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -#nullable enable -namespace ServiceControl.Infrastructure.Tests.Auth; - -using System; -using System.Linq; -using System.Security.Claims; -using NUnit.Framework; -using ServiceControl.Configuration; -using ServiceControl.Infrastructure; -using ServiceControl.Infrastructure.Auth; - -[TestFixture] -class EffectivePermissionsTests -{ - static readonly SettingsRootNamespace TestNamespace = new("ServiceControl"); - - static ClaimsPrincipal PrincipalWithRoles(params string[] roles) => - new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "test")); - - [TearDown] - public void TearDown() - { - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null); - } - - [Test] - public void Rbac_enabled_returns_the_union_of_role_permissions() - { - Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true"); - var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); - - var result = EffectivePermissions.ForUser(PrincipalWithRoles(RolePermissions.Reader), settings); - - Assert.That(result, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader))); - } - - [Test] - public void Rbac_disabled_returns_all_permissions() - { - var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); - - var result = EffectivePermissions.ForUser(PrincipalWithRoles("anything"), settings); - - Assert.That(result, Is.EquivalentTo(Permissions.All)); - } -} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RolePermissionsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RolePermissionsTests.cs new file mode 100644 index 0000000000..b7edf5a562 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RolePermissionsTests.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Linq; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RolePermissionsTests +{ + [Test] + public void Every_permission_is_assigned_to_a_role() + { + var unassigned = Permissions.All + .Except(RolePermissions.Roles[RolePermissions.Admin]) + .Order() + .ToArray(); + + Assert.That(unassigned, Is.Empty, + $"Every permission constant must be assigned to a role group in RolePermissions. Unassigned: [{string.Join(", ", unassigned)}]"); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs index 1cff8bd5e6..7454a6516f 100644 --- a/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs @@ -1,14 +1,14 @@ #nullable enable namespace ServiceControl.Infrastructure.Auth; +using System; using System.Collections.Generic; -using System.Linq; using System.Security.Claims; /// /// The set of permissions a principal effectively holds, computed per request. Mirrors the inputs the /// enforcement handler uses: when role-based authorization is enabled, the union of the permissions -/// granted by the principal's claims (via ); +/// granted by the principal's claims (via ); /// when it is disabled the platform runs allow-all, so every known permission is held. /// public static class EffectivePermissions @@ -20,7 +20,15 @@ public static IReadOnlySet ForUser(ClaimsPrincipal user, OpenIdConnectSe return Permissions.All; } - var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value); - return RolePermissions.GetPermissions(roles); + var permissions = new HashSet(StringComparer.Ordinal); + foreach (var claim in user.FindAll(ClaimTypes.Role)) + { + if (RolePermissions.Roles.TryGetValue(claim.Value, out var granted)) + { + permissions.UnionWith(granted); + } + } + + return permissions; } } diff --git a/src/ServiceControl.Infrastructure/Auth/Permissions.cs b/src/ServiceControl.Infrastructure/Auth/Permissions.cs index 006fccf0f5..04f6582c14 100644 --- a/src/ServiceControl.Infrastructure/Auth/Permissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/Permissions.cs @@ -85,7 +85,7 @@ public static class Permissions /// public const string ErrorThroughputManage = "error:throughput:manage"; - /// Platform connections area — viewing and managing broker/platform connection settings. + /// Platform connections area — viewing and managing platform connection settings. public const string ErrorConnectionsView = "error:connections:view"; /// public const string ErrorConnectionsManage = "error:connections:manage"; diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs index bb79f98f02..473e9e19d3 100644 --- a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -4,63 +4,82 @@ namespace ServiceControl.Infrastructure.Auth; using System; using System.Collections.Frozen; using System.Collections.Generic; -using System.Linq; -/// -/// Role → permission policy. Two roles: -/// -/// reader — granted every *:*:view permission (read-only access). -/// writer — granted every permission (*:*:*). -/// -/// The wildcard patterns (* is a colon-segment wildcard) are the source of truth, but they are -/// expanded once at type initialization against into a concrete, -/// immutable of granted permissions per role. As a result both -/// and are O(1) hash lookups with no -/// per-call pattern matching or allocation. -/// public static class RolePermissions { - /// Read-only role: every *:*:view permission. public const string Reader = "reader"; - - /// Full-access role: every permission. public const string Writer = "writer"; - - /// - /// Platform-administrator role: read-only on everything, plus full management of the configuration / - /// admin-area resources (licensing, notifications, retry redirects, throughput, connections) — but - /// not the message-triage write actions (retry/edit/archive/restore). - /// public const string Admin = "admin"; - // Source of truth: the wildcard pattern(s) each role grants. - static readonly Dictionary RolePatterns = new(StringComparer.OrdinalIgnoreCase) - { - [Reader] = ["*:*:view"], - [Writer] = ["*:*:*"], - [Admin] = - [ - "*:*:view", - "error:licensing:*", - "error:notifications:*", - "error:redirects:*", - "error:throughput:*", - "error:connections:*", - ], - }; + static readonly string[] Read = + [ + Permissions.ErrorMessagesView, + Permissions.ErrorRecoverabilityGroupsView, + Permissions.ErrorEndpointsView, + Permissions.ErrorHeartbeatsView, + Permissions.ErrorCustomChecksView, + Permissions.ErrorSagasView, + Permissions.ErrorEventLogView, + Permissions.ErrorQueuesView, + Permissions.ErrorConnectionsView, + Permissions.AuditMessageView, + Permissions.AuditConnectionView, + Permissions.AuditEndpointView, + Permissions.AuditSagaView, + Permissions.MonitoringEndpointView, + Permissions.MonitoringConnectionView, + Permissions.MonitoringLicenseView, + ]; + + static readonly string[] ReadConfiguration = + [ + Permissions.ErrorLicensingView, + Permissions.ErrorNotificationsView, + Permissions.ErrorRedirectsView, + Permissions.ErrorThroughputView, + ]; + + static readonly string[] Operate = + [ + Permissions.ErrorMessagesRetry, + Permissions.ErrorMessagesArchive, + Permissions.ErrorMessagesUnarchive, + Permissions.ErrorMessagesEdit, + Permissions.ErrorRecoverabilityGroupsRetry, + Permissions.ErrorRecoverabilityGroupsArchive, + Permissions.ErrorRecoverabilityGroupsUnarchive, + Permissions.ErrorEndpointsManage, + Permissions.ErrorEndpointsDelete, + Permissions.ErrorCustomChecksDelete, + Permissions.ErrorQueuesDelete, + Permissions.ErrorConnectionsManage, + Permissions.MonitoringEndpointDelete, + ]; + + static readonly string[] Configure = + [ + Permissions.ErrorLicensingManage, + Permissions.ErrorNotificationsManage, + Permissions.ErrorNotificationsTest, + Permissions.ErrorRedirectsManage, + Permissions.ErrorThroughputManage, + ]; + + public static readonly FrozenDictionary> Roles = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [Reader] = ToSet([.. Read, .. ReadConfiguration]), + [Writer] = ToSet([.. Read, .. Operate]), + [Admin] = ToSet([.. Read, .. ReadConfiguration, .. Operate, .. Configure]), + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - // Expanded once against the full permission catalogue: role -> concrete granted permissions. - static readonly FrozenDictionary> PermissionsByRole = Expand(); + static FrozenSet ToSet(string[] permissions) => permissions.ToFrozenSet(StringComparer.Ordinal); - /// - /// Returns if any of the supplied grants the - /// requested . O(1) per role — a frozen-set membership test. - /// - public static bool IsGranted(IEnumerable roles, string permission) + public static bool IsGranted(string[] roles, string permission) { foreach (var role in roles) { - if (PermissionsByRole.TryGetValue(role, out var granted) && granted.Contains(permission)) + if (Roles.TryGetValue(role, out var granted) && granted.Contains(permission)) { return true; } @@ -68,72 +87,4 @@ public static bool IsGranted(IEnumerable roles, string permission) return false; } - - /// - /// The complete set of permissions granted to a single role (empty if the role is unknown). - /// O(1) and allocation-free — returns the precomputed frozen set. - /// - public static IReadOnlySet GetPermissions(string role) => - PermissionsByRole.TryGetValue(role, out var granted) ? granted : FrozenSet.Empty; - - /// - /// The union of permissions granted across several . Allocation-free for the - /// common single-role case; only the multi-role union allocates. - /// - public static IReadOnlySet GetPermissions(IEnumerable roles) - { - var list = roles as IReadOnlyList ?? roles.ToList(); - if (list.Count <= 1) - { - return list.Count == 0 ? FrozenSet.Empty : GetPermissions(list[0]); - } - - var union = new HashSet(StringComparer.Ordinal); - foreach (var role in list) - { - if (PermissionsByRole.TryGetValue(role, out var granted)) - { - union.UnionWith(granted); - } - } - - return union; - } - - static FrozenDictionary> Expand() - { - var expanded = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (var (role, patterns) in RolePatterns) - { - expanded[role] = Permissions.All - .Where(permission => patterns.Any(pattern => Matches(pattern, permission))) - .ToFrozenSet(StringComparer.Ordinal); - } - - return expanded.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - } - - /// Matches a colon-delimited permission against a pattern where * is a segment wildcard. - static bool Matches(string pattern, string permission) - { - var patternSegments = pattern.Split(':'); - var permissionSegments = permission.Split(':'); - - if (patternSegments.Length != permissionSegments.Length) - { - return false; - } - - for (var i = 0; i < patternSegments.Length; i++) - { - if (patternSegments[i] != "*" - && !string.Equals(patternSegments[i], permissionSegments[i], StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - return true; - } } diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 82ce6a9d20..963f727b86 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -94,7 +94,7 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - // The "reader" role grants every *:*:view permission, including + // The "reader" role grants every :view permission, including // monitoring:endpoint:view required by /monitored-endpoints. Without a // role-bearing claim the request would be 403. var validToken = mockOidcServer.GenerateToken( From e98efdab98b4f7746f618d24e964551ffa1f2407 Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Fri, 3 Jul 2026 09:34:53 +0200 Subject: [PATCH 32/53] Pin the my/routes manifest to one wire shape across instances (#5573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Pin my/routes manifest field names to snake_case across instances The Primary instance serializes JSON snake_case while the Monitoring instance serializes camelCase, so the same my/routes contract emitted url_template on one and urlTemplate on the other. A client merging both manifests would silently drop every entry from the differently-cased instance. Pin the field names on RouteManifestEntry with [JsonPropertyName] so every host emits an identical { method, url_template } shape regardless of its global JSON naming policy. * ✅ Guard the my/routes manifest snake_case wire contract The acceptance test deserializes the response into the C# record, which is casing-agnostic, so it would not catch a regression of the emitted field names. Add a serialization test asserting RouteManifestEntry emits snake_case even under a camelCase policy (as the Monitoring instance uses), pinning the contract that [JsonPropertyName] enforces. --- .../When_my_routes_are_requested.cs | 2 +- .../Auth/MyRoutesController.cs | 2 +- .../RouteManifestEntrySerializationTests.cs | 27 +++++++++++++++++++ .../Auth/RouteManifest.cs | 13 +++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs index 97888c183c..a6194367b7 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -13,7 +13,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect; using ServiceControl.Infrastructure.Auth; /// -/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries. +/// my/routes returns the API routes the current token may call, as { method, url_template } entries. /// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it /// already calls rather than on the server's internal permission vocabulary. /// diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs index 28a6d36ec1..9d7194bb4a 100644 --- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Hosting.Auth; using ServiceControl.Infrastructure.Auth; /// -/// Returns the API routes the current token may call, as { method, urlTemplate } entries. +/// Returns the API routes the current token may call, as { method, url_template } entries. /// This is the per-instance authorization contract for clients (ServicePulse): each instance reports /// only the routes it serves, so a client matches its outgoing request against the allowed set without /// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs new file mode 100644 index 0000000000..664f56b190 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Text.Json; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteManifestEntrySerializationTests +{ + // The my/routes manifest must have ONE wire shape across instances. The Primary instance + // serializes snake_case and the Monitoring instance camelCase, so RouteManifestEntry pins its + // field names with [JsonPropertyName]. This test guards that contract: even under a camelCase + // policy (as on the Monitoring host) the emitted names stay snake_case, so a client merging both + // instances never silently drops the differently-cased entries. + [Test] + public void Emits_snake_case_field_names_even_under_a_camelCase_policy() + { + var json = JsonSerializer.Serialize( + new RouteManifestEntry("GET", "/api/errors"), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + Assert.That(json, Does.Contain("\"method\"")); + Assert.That(json, Does.Contain("\"url_template\"")); + Assert.That(json, Does.Not.Contain("urlTemplate")); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs index c697a056f2..d583803f62 100644 --- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; /// @@ -28,8 +29,16 @@ public static string Normalize(string rawTemplate) /// A route the server hosts, with the authorization metadata read from its endpoint. public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous); -/// A single allowed-route entry returned to the client. -public sealed record RouteManifestEntry(string Method, string UrlTemplate); +/// +/// A single allowed-route entry returned to the client. The JSON field names are pinned with +/// so the manifest has one stable shape regardless of each host's +/// global JSON naming policy (the Primary instance serializes snake_case, the Monitoring instance +/// camelCase). Without this the same contract would emit url_template on one instance and +/// urlTemplate on another, and clients that merge both would silently drop half the routes. +/// +public sealed record RouteManifestEntry( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("url_template")] string UrlTemplate); /// /// Projects the route table down to the entries a caller may invoke. A route is included when it is From 6699e1c19d6991d96670e59e8b752104cb090846 Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Fri, 3 Jul 2026 13:03:39 +0200 Subject: [PATCH 33/53] Include user roles in routes manifest (#5574) * Include user rols in routes manifest * Use verify instead --- src/Directory.Packages.props | 1 + .../Auth/MyRoutesController.cs | 19 +++++++++++-------- ...utes_under_pinned_field_names.verified.txt | 11 +++++++++++ .../RouteManifestEntrySerializationTests.cs | 14 ++++++++++++++ ...ServiceControl.Infrastructure.Tests.csproj | 1 + .../Auth/RouteManifest.cs | 10 ++++++++++ 6 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1932243700..a704e57918 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -82,6 +82,7 @@ + diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs index 9d7194bb4a..d01d8eae88 100644 --- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -1,18 +1,20 @@ #nullable enable namespace ServiceControl.Hosting.Auth; -using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// -/// Returns the API routes the current token may call, as { method, url_template } entries. -/// This is the per-instance authorization contract for clients (ServicePulse): each instance reports -/// only the routes it serves, so a client matches its outgoing request against the allowed set without -/// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that -/// contract, so it is reachable by any authenticated user ([Authorize], no specific permission). +/// Returns the caller's role claims and the API routes the current token may call, as +/// { method, url_template } entries. This is the per-instance authorization contract for +/// clients (ServicePulse): each instance reports only the routes it serves, so a client matches its +/// outgoing request against the allowed set without ever learning the server's internal permission +/// vocabulary. The endpoint is the bootstrap of that contract, so it is reachable by any authenticated +/// user ([Authorize], no specific permission). /// [ApiController] [Route("api")] @@ -21,9 +23,10 @@ public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConn { [HttpGet] [Route("my/routes")] - public ActionResult> GetMyRoutes() + public ActionResult GetMyRoutes() { var effective = EffectivePermissions.ForUser(User, settings); - return Ok(RouteManifestFilter.Filter(table.Entries, effective)); + var roles = User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).Distinct().ToArray(); + return Ok(new MyRoutesResponse(roles, RouteManifestFilter.Filter(table.Entries, effective))); } } diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt new file mode 100644 index 0000000000..ed2dd257ef --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt @@ -0,0 +1,11 @@ +{ + roles: [ + admin + ], + routes: [ + { + method: GET, + url_template: /api/errors + } + ] +} \ No newline at end of file diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs index 664f56b190..3d72610362 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -2,8 +2,10 @@ namespace ServiceControl.Infrastructure.Tests.Auth; using System.Text.Json; +using System.Threading.Tasks; using NUnit.Framework; using ServiceControl.Infrastructure.Auth; +using VerifyNUnit; [TestFixture] class RouteManifestEntrySerializationTests @@ -24,4 +26,16 @@ public void Emits_snake_case_field_names_even_under_a_camelCase_policy() Assert.That(json, Does.Contain("\"url_template\"")); Assert.That(json, Does.Not.Contain("urlTemplate")); } + + // Same contract as above, but for the response wrapper: roles are reported once at the top level, + // not duplicated onto every route entry. + [Test] + public Task Response_wraps_roles_and_routes_under_pinned_field_names() + { + var json = JsonSerializer.Serialize( + new MyRoutesResponse(["admin"], [new RouteManifestEntry("GET", "/api/errors")]), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + return Verifier.VerifyJson(json); + } } diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 4cddac2b26..11d4e96b5a 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -15,6 +15,7 @@ + \ No newline at end of file diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs index d583803f62..6622b14cde 100644 --- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -40,6 +40,16 @@ public sealed record RouteManifestEntry( [property: JsonPropertyName("method")] string Method, [property: JsonPropertyName("url_template")] string UrlTemplate); +/// +/// The full my/routes payload: the caller's role claims alongside the routes they may invoke. Roles +/// are reported once at the top level rather than repeated per entry, since they describe the caller, +/// not the individual route. Field names are pinned for the same cross-instance reason as +/// . +/// +public sealed record MyRoutesResponse( + [property: JsonPropertyName("roles")] IReadOnlyList Roles, + [property: JsonPropertyName("routes")] IReadOnlyList Routes); + /// /// Projects the route table down to the entries a caller may invoke. A route is included when it is /// anonymous, requires only authentication (no specific permission), or its required permission is in From 7f5f2cac3af107b407e800f1269df569cbed1827 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 3 Jul 2026 13:26:34 +0200 Subject: [PATCH 34/53] =?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 35/53] =?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 36/53] =?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 37/53] 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 38/53] 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 b89f22eace102e98f2c17ec8b13af2f23f56160d Mon Sep 17 00:00:00 2001 From: WilliamBZA Date: Mon, 6 Jul 2026 12:59:59 +0200 Subject: [PATCH 39/53] Use correct permissions for roles (#5578) --- src/ServiceControl.Infrastructure/Auth/RolePermissions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs index 473e9e19d3..1263d43f1e 100644 --- a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -36,7 +36,6 @@ public static class RolePermissions Permissions.ErrorLicensingView, Permissions.ErrorNotificationsView, Permissions.ErrorRedirectsView, - Permissions.ErrorThroughputView, ]; static readonly string[] Operate = @@ -62,6 +61,7 @@ public static class RolePermissions Permissions.ErrorNotificationsManage, Permissions.ErrorNotificationsTest, Permissions.ErrorRedirectsManage, + Permissions.ErrorThroughputView, Permissions.ErrorThroughputManage, ]; @@ -69,7 +69,7 @@ public static class RolePermissions new Dictionary>(StringComparer.OrdinalIgnoreCase) { [Reader] = ToSet([.. Read, .. ReadConfiguration]), - [Writer] = ToSet([.. Read, .. Operate]), + [Writer] = ToSet([.. Read, .. ReadConfiguration, .. Operate]), [Admin] = ToSet([.. Read, .. ReadConfiguration, .. Operate, .. Configure]), }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); From 92d6f946ad60aa6967e5404d66dac6ac8d2a16e5 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 6 Jul 2026 15:00:53 +0200 Subject: [PATCH 40/53] 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 41/53] 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 42/53] 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 43/53] =?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 44/53] =?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 45/53] =?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 46/53] =?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 47/53] =?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 48/53] =?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 49/53] =?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 50/53] =?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 51/53] =?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 52/53] =?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 53/53] =?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; }