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/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.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.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.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.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 4e8d11e9c5..b88df37305 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; @@ -35,6 +36,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithServicePulseClientId(TestClientId) @@ -124,7 +126,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.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..a6194367b7 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -0,0 +1,67 @@ +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, 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. +/// +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); + } + + class Context : ScenarioContext; +} diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 7f83ffc593..c2c4e39e31 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -127,6 +127,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.Services.AddScenarioContext(context); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControl(settings, configuration); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlApi(settings.CorsSettings); 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.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index c07cb16aa9..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 @@ -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; @@ -32,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); @@ -92,7 +94,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.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index dcefea98f0..249e0f250e 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -122,6 +122,7 @@ async Task InitializeServiceControl(ScenarioContext context) }); hostBuilder.Services.AddScenarioContext(context); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem 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.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 83897faeba..4a1b91b263 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,9 +12,13 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, 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/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index 5bf0db7b5c..5d5d8753eb 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); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlAudit((_, __) => { diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs index 200a33f45e..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"]); + 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/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.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.Audit/WebApplicationExtensions.cs b/src/ServiceControl.Audit/WebApplicationExtensions.cs index 76785dd77d..1a3ec118ff 100644 --- a/src/ServiceControl.Audit/WebApplicationExtensions.cs +++ b/src/ServiceControl.Audit/WebApplicationExtensions.cs @@ -4,12 +4,14 @@ namespace ServiceControl.Audit; using Microsoft.AspNetCore.Builder; 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) { + app.UseRequestIdHeader(); app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression(); 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/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index f425e7afb2..08c4db3cf3 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -3,9 +3,11 @@ 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; + using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using ServiceControl.Infrastructure; @@ -20,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"; @@ -36,7 +43,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 @@ -99,6 +107,11 @@ 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, read off the injected settings. + hostBuilder.Services.AddSingleton(); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs new file mode 100644 index 0000000000..d01d8eae88 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -0,0 +1,32 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +/// +/// 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")] +[Authorize] +public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConnectSettings settings) : ControllerBase +{ + [HttpGet] + [Route("my/routes")] + public ActionResult GetMyRoutes() + { + var effective = EffectivePermissions.ForUser(User, settings); + 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.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs new file mode 100644 index 0000000000..ebc000cf8b --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -0,0 +1,64 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +/// +/// 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, OpenIdConnectSettings oidcSettings) + { + 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(); + + // 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(); + + // 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(); + + // 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 + // the EndpointDataSource the framework registers, so it sees exactly the routes that are served. + services.AddSingleton(); + } +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs new file mode 100644 index 0000000000..500e50c335 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -0,0 +1,67 @@ +#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; +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, 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(oidcSettings); + + static FrozenDictionary BuildPolicies(OpenIdConnectSettings oidcSettings) => + Permissions.All.ToFrozenDictionary( + permission => permission, + permission => oidcSettings.RoleBasedAuthorizationEnabled + ? 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); + + 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..138827331f --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -0,0 +1,74 @@ +#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. 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( + IAuthorizationAuditLog auditLog, + OpenIdConnectSettings oidcSettings) + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + // 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 = 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; + + 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; + } + + 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; + } +} \ No newline at end of file diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs new file mode 100644 index 0000000000..f2641c80a1 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -0,0 +1,56 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using ServiceControl.Infrastructure; +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(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"; + + public Task TransformAsync(ClaimsPrincipal principal) + { + var isAuthenticated = principal.Identity?.IsAuthenticated == true; + if (!isAuthenticated || AlreadyTransformed(principal)) + { + return Task.FromResult(principal); + } + + 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(ClaimTypes.Role, 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); + } + + // 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); +} 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.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.Infrastructure.Tests/Auth/AuditHeadersTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs new file mode 100644 index 0000000000..171e1ce318 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs @@ -0,0 +1,47 @@ +#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_name_and_operation_headers() + { + var options = new SendOptions(); + 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_and_operation() + { + var headers = new Dictionary + { + [AuditHeaders.SubjectId] = "alice-sub", + [AuditHeaders.SubjectName] = "Alice", + [AuditHeaders.OperationId] = "op-123" + }; + + 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() + { + 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.Tests/Auth/AuthorizationAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/AuthorizationAuditLogTests.cs new file mode 100644 index 0000000000..1db40a1b6c --- /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").TryGetProperty("resource", out _), Is.False, "null resource should be omitted"); + 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/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.Tests/Auth/MessageActionAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs new file mode 100644 index 0000000000..f2092f5022 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/MessageActionAuditLogTests.cs @@ -0,0 +1,162 @@ +#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(System.Action? configure = null) + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => + { + b.AddProvider(provider); + configure?.Invoke(b); + }); + 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")); + } + + [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")); + } + } + + [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)] + [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.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/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.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.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 new file mode 100644 index 0000000000..3d72610362 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -0,0 +1,41 @@ +#nullable enable +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 +{ + // 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")); + } + + // 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/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.Tests/LoggingConfiguratorTests.cs b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs new file mode 100644 index 0000000000..3e32ba8d39 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs @@ -0,0 +1,118 @@ +#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").TryGetProperty("resource", out _), Is.False, "absent resource should be omitted from the JSON entirely, not present as 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); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 4cddac2b26..7664d8aa63 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -7,14 +7,17 @@ + + + \ No newline at end of file diff --git a/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs new file mode 100644 index 0000000000..0ad71f9604 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs @@ -0,0 +1,53 @@ +#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 const string OperationId = "ServiceControl.Audit.OperationId"; + + 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); + } + } + + /// + /// 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); + + 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), operationId); + } + + return (AuditUser.Anonymous, operationId); + } +} 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/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs new file mode 100644 index 0000000000..83e75e0f7c --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/AuthorizationAuditLog.cs @@ -0,0 +1,82 @@ +#nullable enable +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; + +/// +/// 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 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. 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) + { + ArgumentException.ThrowIfNullOrEmpty(subjectId); + ArgumentException.ThrowIfNullOrEmpty(subjectName); + ArgumentException.ThrowIfNullOrEmpty(permission); + ArgumentException.ThrowIfNullOrEmpty(reason); + + var level = allowed ? LogLevel.Information : LogLevel.Warning; + if (!logger.IsEnabled(level)) + { + 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 + // 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); + } + + // 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/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/EffectivePermissions.cs b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs new file mode 100644 index 0000000000..7454a6516f --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs @@ -0,0 +1,34 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +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 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/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/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); +} 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/MessageAction.cs b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs new file mode 100644 index 0000000000..37deffca8d --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/MessageAction.cs @@ -0,0 +1,23 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +/// The kind of recoverability action being audited. Determines the ECS event.type. +public enum MessageActionKind +{ + Retry, + Archive, + Unarchive, + Edit +} + +/// How the action selected the messages it acts on. +public enum MessageActionScope +{ + Single, + Batch, + Group, + Queue, + Endpoint, + All, + Range +} diff --git a/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs new file mode 100644 index 0000000000..abe63658b4 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/MessageActionAuditLog.cs @@ -0,0 +1,118 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +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 class MessageActionAuditLog : IMessageActionAuditLog +{ + public const string OperationCategory = AuthorizationAuditLog.AuditCategory; // "ServiceControl.Audit" + public const string MessageCategory = AuthorizationAuditLog.AuditCategory + ".Messages"; // "ServiceControl.Audit.Messages" + + 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); + + // 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); + 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) + { + ArgumentException.ThrowIfNullOrEmpty(permission); + 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. + 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); + 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) + { + 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 = ScopeName(scope), + resource, + message = messageId is null ? null : new { id = messageId }, + count, + operation = new { id = operationId } + } + }; + + 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() + }; + + // 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; +} 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.Infrastructure/Auth/Permissions.cs b/src/ServiceControl.Infrastructure/Auth/Permissions.cs new file mode 100644 index 0000000000..04f6582c14 --- /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 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.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs new file mode 100644 index 0000000000..1263d43f1e --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -0,0 +1,90 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; + +public static class RolePermissions +{ + public const string Reader = "reader"; + public const string Writer = "writer"; + public const string Admin = "admin"; + + 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, + ]; + + 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.ErrorThroughputView, + Permissions.ErrorThroughputManage, + ]; + + public static readonly FrozenDictionary> Roles = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [Reader] = ToSet([.. Read, .. ReadConfiguration]), + [Writer] = ToSet([.. Read, .. ReadConfiguration, .. Operate]), + [Admin] = ToSet([.. Read, .. ReadConfiguration, .. Operate, .. Configure]), + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + static FrozenSet ToSet(string[] permissions) => permissions.ToFrozenSet(StringComparer.Ordinal); + + public static bool IsGranted(string[] roles, string permission) + { + foreach (var role in roles) + { + if (Roles.TryGetValue(role, out var granted) && granted.Contains(permission)) + { + return true; + } + } + + return false; + } +} 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/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs new file mode 100644 index 0000000000..6622b14cde --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -0,0 +1,70 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +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. 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); + +/// +/// 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 +/// 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.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(); + }); } } 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 efbba73239..33f0e461a0 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -32,6 +32,16 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); + 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) @@ -96,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. @@ -114,6 +138,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) @@ -187,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}, 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}, 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.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index d212dd8f1a..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 @@ -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; @@ -32,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); @@ -92,7 +94,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, diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 4bb4fe2c12..a5d7623d79 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -104,6 +104,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.Services.AddScenarioContext(context); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem 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.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index c4cfe5ad09..a35b4112e2 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -12,9 +12,13 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, 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/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index ca648ac222..6e197e463a 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); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); 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.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.Monitoring/WebApplicationExtensions.cs b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs index fad91eef55..1d840240bc 100644 --- a/src/ServiceControl.Monitoring/WebApplicationExtensions.cs +++ b/src/ServiceControl.Monitoring/WebApplicationExtensions.cs @@ -3,12 +3,14 @@ namespace ServiceControl.Monitoring.Infrastructure; using Microsoft.AspNetCore.Builder; 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) { + appBuilder.UseRequestIdHeader(); appBuilder.UseServiceControlForwardedHeaders(forwardedHeadersSettings); appBuilder.UseServiceControlHttps(httpsSettings); @@ -30,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"]); + 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.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/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 b2957ba9a8..d0adadba88 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) @@ -82,7 +89,7 @@ public async Task ArchiveAllInGroup(string groupId) 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); @@ -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) @@ -174,7 +189,7 @@ public async Task UnarchiveAllInGroup(string groupId) 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); @@ -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..55fcc5f300 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Archiving/ArchiveGroupPerMessageAuditTests.cs @@ -0,0 +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; + +[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.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); + } +} 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(); 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/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs new file mode 100644 index 0000000000..a22a45e2fd --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs @@ -0,0 +1,111 @@ +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 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, + [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; + } +} \ No newline at end of file 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.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..45f89dddd9 100644 --- a/src/ServiceControl.Persistence/RetryBatch.cs +++ b/src/ServiceControl.Persistence/RetryBatch.cs @@ -19,6 +19,14 @@ public class RetryBatch public RetryBatchStatus Status { get; set; } public IList FailureRetries { get; set; } = []; + // 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; } + public static string MakeDocumentId(string messageUniqueId) => "RetryBatches/" + messageUniqueId; } } \ 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.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 6873e229b3..6e7be8475f 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -12,9 +12,13 @@ "ValidateLifetime": true, "ValidateIssuerSigningKey": true, "RequireHttpsMetadata": true, + "SubjectIdClaim": "sub", + "SubjectNameClaim": "preferred_username", "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, 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.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/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.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/ArchiveUnarchivePendingAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs new file mode 100644 index 0000000000..34706994d4 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/ArchiveUnarchivePendingAuditTests.cs @@ -0,0 +1,107 @@ +#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 +{ + 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); + } +} diff --git a/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs new file mode 100644 index 0000000000..ffb2dcae62 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/AsyncRangeAndQueueAuditTests.cs @@ -0,0 +1,197 @@ +#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; +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 + }; + + // 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_forwards_attribution_to_the_staged_retry() + { + var store = new StubErrorMessageDataStore { RetryPendingMessagesResult = ["m-1", "m-2"] }; + 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); + + var sent = context.SentMessages.Single(m => m.Message is RetryMessagesById); + var headers = sent.Options.GetHeaders(); + using (Assert.EnterMultipleScope()) + { + 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_forwards_attribution_to_the_staged_retry() + { + 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); + + var sent = context.SentMessages.Single(m => m.Message is RetryMessagesById); + var headers = sent.Options.GetHeaders(); + using (Assert.EnterMultipleScope()) + { + 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")); + } + } + + [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)); + } + } + + internal 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/EditFailedMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs new file mode 100644 index 0000000000..96cbb10995 --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/EditFailedMessagesControllerAuditTests.cs @@ -0,0 +1,102 @@ +#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)); + } + + 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.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.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs new file mode 100644 index 0000000000..942d94927d --- /dev/null +++ b/src/ServiceControl.UnitTests/MessageFailures/RetryMessagesControllerAuditTests.cs @@ -0,0 +1,84 @@ +#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 +{ + 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/FailureGroupsArchiveUnarchiveAuditTests.cs b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs new file mode 100644 index 0000000000..40ff070a7d --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsArchiveUnarchiveAuditTests.cs @@ -0,0 +1,69 @@ +#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 +{ + 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")); + } + + [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 new file mode 100644 index 0000000000..e332202078 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/FailureGroupsRetryControllerAuditTests.cs @@ -0,0 +1,53 @@ +#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; + +[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)); + } + + [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/LockedHeaderModificationValidatorTests.cs b/src/ServiceControl.UnitTests/Recoverability/LockedHeaderModificationValidatorTests.cs index 82c0646ccc..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 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 diff --git a/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs new file mode 100644 index 0000000000..b5a8a70bfe --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/NoopArchiveMessages.cs @@ -0,0 +1,31 @@ +#nullable enable +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 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) => OperationInProgress; + + 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.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs new file mode 100644 index 0000000000..7073b15ed3 --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/RecordingMessageActionAuditLog.cs @@ -0,0 +1,20 @@ +#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; } = []; + + 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..94ea9df4eb --- /dev/null +++ b/src/ServiceControl.UnitTests/Recoverability/StubCurrentUserAccessor.cs @@ -0,0 +1,10 @@ +#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; +} 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/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 diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index e694fb4acb..ac47c46850 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); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(settings.CorsSettings); 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/AuditOperationIdExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs new file mode 100644 index 0000000000..ff412d28fe --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/AuditOperationIdExtensions.cs @@ -0,0 +1,20 @@ +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..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"]); + 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/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(); 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..d00451129b 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -1,9 +1,12 @@ namespace ServiceControl.MessageFailures.Api { + using System; 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; @@ -11,8 +14,9 @@ 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")] [HttpPost] [HttpPatch] @@ -24,16 +28,21 @@ public async Task ArchiveBatch(string[] messageIds) return UnprocessableEntity(ModelState); } - foreach (var id in messageIds) - { - var request = new ArchiveMessage { FailedMessageId = id }; - - await messageSession.SendLocal(request); - } + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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, Scope = MessageActionScope.Batch }, AuditHeaders.LocalSendOptions(user, operationId)); + } + }); return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/groups/{classifier?}")] [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace") @@ -45,16 +54,22 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex return Ok(results); } + [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/{messageId:required:minlength(1)}/archive")] [HttpPost] [HttpPatch] public async Task Archive(string messageId) { - await messageSession.SendLocal(m => m.FailedMessageId = messageId); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + await auditLog.AuditedOperation(user, MessageActionKind.Archive, Permissions.ErrorMessagesArchive, MessageActionScope.Single, + resource: messageId, count: 1, operationId: operationId, + () => messageSession.Send(new ArchiveMessage { FailedMessageId = messageId, Scope = MessageActionScope.Single }, AuditHeaders.LocalSendOptions(user, operationId))); 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..20acc62019 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -5,6 +5,9 @@ using System.Linq; 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; using NServiceBus; @@ -18,13 +21,17 @@ public class EditFailedMessagesController( Settings settings, IErrorMessageDataStore store, IMessageSession session, - ILogger logger) + ILogger logger, + ICurrentUserAccessor userAccessor, + IMessageActionAuditLog auditLog) : 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) @@ -75,14 +82,20 @@ public async Task> Edit(string failedMessageId, return BadRequest(); } + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + // 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 - { - FailedMessageId = failedMessageId, - NewBody = base64String, - NewHeaders = edit.MessageHeaders - }); + + 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/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..e0b62e3576 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -5,14 +5,18 @@ using System.Linq; 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; using NServiceBus; [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")] [HttpPost] public async Task RetryBy(string[] ids) @@ -23,21 +27,30 @@ public async Task RetryBy(string[] ids) return UnprocessableEntity(ModelState); } - await session.SendLocal(m => m.MessageUniqueIds = ids); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/queues/retry")] [HttpPost] public async Task RetryBy(PendingRetryRequest request) { - await session.SendLocal(m => - { - m.QueueAddress = request.QueueAddress; - m.PeriodFrom = request.From; - m.PeriodTo = request.To; - }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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/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..f09c72e280 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -1,10 +1,14 @@ namespace ServiceControl.MessageFailures.Api { + using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using Infrastructure.Auth; + using Infrastructure.WebApi; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -21,15 +25,22 @@ 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")] [HttpPost] public async Task RetryMessageBy([FromQuery(Name = "instance_id")] string instanceId, string failedMessageId) { if (string.IsNullOrWhiteSpace(instanceId) || instanceId == settings.InstanceId) { - await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } @@ -49,6 +60,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) @@ -58,38 +70,57 @@ public async Task RetryAllBy(List messageIds) return BadRequest(); } - await messageSession.SendLocal(m => m.MessageUniqueIds = messageIds.ToArray()); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/queues/{queueAddress:required:minlength(1)}/retry")] [HttpPost] public async Task RetryAllBy(string queueAddress) { - await messageSession.SendLocal(m => - { - m.QueueAddress = queueAddress; - m.Status = FailedMessageStatus.Unresolved; - }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/retry/all")] [HttpPost] public async Task RetryAll() { - await messageSession.SendLocal(new RequestRetryAll()); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{endpointName:required:minlength(1)}/retry/all")] [HttpPost] public async Task RetryAllByEndpoint(string endpointName) { - await messageSession.SendLocal(new RequestRetryAll { Endpoint = endpointName }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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 0a0271d9fa..bcd37d2040 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -4,14 +4,18 @@ using System.Globalization; 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; [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")] [HttpPatch] public async Task Unarchive(string[] ids) @@ -21,13 +25,16 @@ public async Task Unarchive(string[] ids) return BadRequest(); } - var request = new UnArchiveMessages { FailedMessageIds = ids }; - - await session.SendLocal(request); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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(); } + [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/{from}...{to}/unarchive")] [HttpPatch] public async Task Unarchive(string from, string to) @@ -44,7 +51,11 @@ public async Task Unarchive(string from, string to) return BadRequest(); } - await session.SendLocal(new UnArchiveMessagesByRange { From = fromDateTime, To = toDateTime }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + 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/MessageFailures/Handlers/ArchiveMessageHandler.cs b/src/ServiceControl/MessageFailures/Handlers/ArchiveMessageHandler.cs index 6bbdd411d3..6169f2b4aa 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, message.Scope, 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/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 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..ec152c57bf 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -1,23 +1,38 @@ 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; using ServiceControl.Persistence.Recoverability; [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) { if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { - await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); - - await bus.SendLocal(m => { m.GroupId = groupId; }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + await auditLog.AuditedOperation(user, MessageActionKind.Archive, + Permissions.ErrorRecoverabilityGroupsArchive, MessageActionScope.Group, + 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/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..423237a31e 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -2,14 +2,22 @@ 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; using ServiceControl.Persistence; [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")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) @@ -18,13 +26,19 @@ public async Task ArchiveGroupErrors(string groupId) if (!retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) { - await retryingManager.Wait(groupId, RetryType.FailureGroup, started); - - await bus.SendLocal(new RetryAllInGroup - { - GroupId = groupId, - Started = started - }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + await auditLog.AuditedOperation(user, MessageActionKind.Retry, + Permissions.ErrorRecoverabilityGroupsRetry, MessageActionScope.Group, + 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 5661c8dd78..3671a35bc2 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -1,23 +1,38 @@ 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; using ServiceControl.Persistence.Recoverability; [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) { if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { - await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); - - await bus.SendLocal(m => { m.GroupId = groupId; }); + var user = userAccessor.Resolve(User); + var operationId = this.AuditOperationId(); + await auditLog.AuditedOperation(user, MessageActionKind.Unarchive, + Permissions.ErrorRecoverabilityGroupsUnarchive, MessageActionScope.Group, + 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(); 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/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/Editing/EditHandler.cs b/src/ServiceControl/Recoverability/Editing/EditHandler.cs index 92b74745a2..1ff4ca428f 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) @@ -75,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 diff --git a/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs b/src/ServiceControl/Recoverability/Retrying/Handlers/PendingRetriesHandler.cs index 62cde7fe7f..6177883caa 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; @@ -27,7 +28,7 @@ public async Task Handle(RetryPendingMessages message, IMessageHandlerContext co messageIds.Add(id); } - await context.SendLocal(new RetryMessagesById { MessageUniqueIds = messageIds.ToArray() }); + await SendRetryMessagesById(context, messageIds.ToArray()); } public async Task Handle(RetryPendingMessagesById message, IMessageHandlerContext context) @@ -37,9 +38,23 @@ public async Task Handle(RetryPendingMessagesById message, IMessageHandlerContex await dataStore.RemoveFailedMessageRetryDocument(messageUniqueId); } - 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; } -} \ 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..f437339b1e 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,39 @@ await domainEvents.Raise(new MessagesSubmittedForRetry return messages.Length; } + // 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)) + { + 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 +370,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/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) diff --git a/src/ServiceControl/WebApplicationExtensions.cs b/src/ServiceControl/WebApplicationExtensions.cs index 685bc7dc16..b6cbc520c8 100644 --- a/src/ServiceControl/WebApplicationExtensions.cs +++ b/src/ServiceControl/WebApplicationExtensions.cs @@ -5,12 +5,14 @@ namespace ServiceControl; using Microsoft.AspNetCore.Builder; 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) { + app.UseRequestIdHeader(); app.UseServiceControlForwardedHeaders(forwardedHeadersSettings); app.UseServiceControlHttps(httpsSettings); app.UseResponseCompression();