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