From 54048fea7fe6ca63cd34ebc4bcf81aa0c4154048 Mon Sep 17 00:00:00 2001 From: williambza Date: Thu, 4 Jun 2026 10:40:35 +0200 Subject: [PATCH] Spike for personalization --- .../Auth/HostApplicationBuilderExtensions.cs | 3 +- .../ErrorMessagesDataStore.cs | 19 +++++-- .../RavenQueryExtensions.cs | 34 +++++++++++ .../Recoverability/GroupsDataStore.cs | 8 ++- .../ErrorMessageDataStoreTests.cs | 2 +- .../ReturnToSenderDequeuerTests.cs | 8 +-- .../IErrorMessageDatastore.cs | 8 +-- .../IGroupsDataStore.cs | 3 +- .../Infrastructure/AuthorizationInfo.cs | 57 +++++++++++++++++++ .../Api/ArchiveMessagesController.cs | 4 +- .../Api/GetAllErrorsController.cs | 15 ++++- .../Api/GetErrorByIdController.cs | 26 ++++++++- .../Properties/launchSettings.json | 3 +- .../API/FailureGroupsController.cs | 4 +- .../Recoverability/API/GroupFetcher.cs | 5 +- 15 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 src/ServiceControl.Persistence/Infrastructure/AuthorizationInfo.cs diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index f425e7afb2..ea28b4785f 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -36,7 +36,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 = "group" }; options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; // Don't map inbound claims to legacy Microsoft claim types diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index df59b8fdf9..bbdea9a546 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -195,13 +195,18 @@ public async Task> GetFailureGroupView(string grou return new QueryResult(document, stats.ToQueryStatsInfo()); } - public async Task> GetFailureGroupsByClassifier(string classifier) + public async Task> GetFailureGroupsByClassifier(string classifier, AuthorizationInfo authInfo) { using var session = await sessionProvider.OpenSession(); var groups = session .Query() .Where(v => v.Type == classifier); + if (classifier == "Endpoint Name" && authInfo?.ReadQueues is { Length: > 0 } readQueues && !readQueues.Contains("*")) + { + groups = groups.Where(v => v.Title.In(readQueues)); + } + var results = await groups .OrderByDescending(x => x.Last) .Take(200) // only show 200 groups @@ -215,7 +220,8 @@ public async Task>> ErrorGet( string modified, string queueAddress, PagingInfo pagingInfo, - SortInfo sortInfo + SortInfo sortInfo, + AuthorizationInfo authInfo ) { using var session = await sessionProvider.OpenSession(); @@ -225,6 +231,7 @@ SortInfo sortInfo .FilterByStatusWhere(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) + .FilterByReadQueuesAuth(authInfo) .Sort(sortInfo) .Paging(pagingInfo) .SelectFields() @@ -240,7 +247,8 @@ SortInfo sortInfo public async Task ErrorsHead( string status, string modified, - string queueAddress + string queueAddress, + AuthorizationInfo authInfo ) { using var session = await sessionProvider.OpenSession(); @@ -249,6 +257,7 @@ string queueAddress .FilterByStatusWhere(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) + .FilterByReadQueuesAuth(authInfo) .GetQueryResultAsync(); return stats.ToQueryStatsInfo(); @@ -259,7 +268,8 @@ public async Task>> ErrorsByEndpointName( string endpointName, string modified, PagingInfo pagingInfo, - SortInfo sortInfo + SortInfo sortInfo, + AuthorizationInfo authInfo ) { using var session = await sessionProvider.OpenSession(); @@ -270,6 +280,7 @@ SortInfo sortInfo .AndAlso() .WhereEquals("ReceivingEndpointName", endpointName) .FilterByLastModifiedRange(modified) + .FilterByReadQueuesAuth(authInfo) .Sort(sortInfo) .Paging(pagingInfo) .SelectFields() diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs index e9b86c43ec..7b9dfe1c2c 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs @@ -5,6 +5,7 @@ namespace ServiceControl.Persistence using System.Globalization; using System.Linq; using System.Linq.Expressions; + using System.Text.RegularExpressions; using Raven.Client.Documents.Linq; using Raven.Client.Documents.Session; using ServiceControl.MessageFailures; @@ -194,6 +195,39 @@ public static IAsyncDocumentQuery FilterByLastModifiedRange(this IAsyncDoc return source; } + public static IAsyncDocumentQuery FilterByReadQueuesAuth(this IAsyncDocumentQuery source, AuthorizationInfo authInfo) + { + if (authInfo?.ReadQueues == null || authInfo.ReadQueues.Length == 0 || authInfo.ReadQueues.Contains("*")) + { + return source; + } + + source.AndAlso(); + source.OpenSubclause(); + var first = true; + foreach (var queue in authInfo.ReadQueues) + { + if (!first) + { + source.OrElse(); + } + + if (queue.Contains('*')) + { + var pattern = "^" + string.Join(".*", queue.ToLowerInvariant().Split('*').Select(Regex.Escape)) + "$"; + source.WhereRegex("QueueAddress", pattern); + } + else + { + source.WhereEquals("QueueAddress", queue.ToLowerInvariant()); + } + first = false; + } + source.CloseSubclause(); + + return source; + } + public static IAsyncDocumentQuery FilterByQueueAddress(this IAsyncDocumentQuery source, string queueAddress) { if (string.IsNullOrWhiteSpace(queueAddress)) diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs index ca7a57bbcf..d777260df4 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/GroupsDataStore.cs @@ -7,11 +7,12 @@ using Raven.Client.Documents; using Raven.Client.Documents.Linq; using ServiceControl.MessageFailures; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; class GroupsDataStore(IRavenSessionProvider sessionProvider) : IGroupsDataStore { - public async Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter) + public async Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter, AuthorizationInfo authInfo) { using var session = await sessionProvider.OpenSession(); var query = Queryable.Where(session.Query(), v => v.Type == classifier); @@ -21,6 +22,11 @@ public async Task> GetFailureGroupsByClassifier(string c query = query.Where(v => v.Title == classifierFilter); } + if (classifier == "Endpoint Name" && authInfo?.ReadQueues is { Length: > 0 } readQueues && !readQueues.Contains("*")) + { + query = query.Where(v => v.Title.In(readQueues)); + } + var groups = await query.OrderByDescending(x => x.Last) .Take(200) .ToListAsync(); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs index 5ef3d88d91..823ff7d968 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/ErrorMessageDataStoreTests.cs @@ -49,7 +49,7 @@ public async Task GetAllMessagesForEndpoint(string sort, string direction, strin [Test] public async Task ErrorGet() { - var result = await store.ErrorGet(null, null, null, new PagingInfo(1, 50), new SortInfo("", "")); + var result = await store.ErrorGet(null, null, null, new PagingInfo(1, 50), new SortInfo("", ""), new AuthorizationInfo()); Assert.That(result.Results, Is.Not.Empty); } diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs index cecd9c9ddc..6ad9843fe7 100644 --- a/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ReturnToSenderDequeuerTests.cs @@ -199,14 +199,14 @@ public Task>> SearchEndpointMessages(string endp public Task> GetFailureGroupView(string groupId, string status, string modified) => throw new NotImplementedException(); - public Task> GetFailureGroupsByClassifier(string classifier) => throw new NotImplementedException(); + public Task> GetFailureGroupsByClassifier(string classifier, AuthorizationInfo authInfo) => throw new NotImplementedException(); - public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) => throw new NotImplementedException(); + public Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo, AuthorizationInfo authInfo) => throw new NotImplementedException(); - public Task ErrorsHead(string status, string modified, string queueAddress) => throw new NotImplementedException(); + public Task ErrorsHead(string status, string modified, string queueAddress, AuthorizationInfo authInfo) => throw new NotImplementedException(); public Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, - SortInfo sortInfo) => + SortInfo sortInfo, AuthorizationInfo authInfo) => throw new NotImplementedException(); public Task> ErrorsSummary() => throw new NotImplementedException(); diff --git a/src/ServiceControl.Persistence/IErrorMessageDatastore.cs b/src/ServiceControl.Persistence/IErrorMessageDatastore.cs index 0dd2c6aad0..ce04409988 100644 --- a/src/ServiceControl.Persistence/IErrorMessageDatastore.cs +++ b/src/ServiceControl.Persistence/IErrorMessageDatastore.cs @@ -23,12 +23,12 @@ public interface IErrorMessageDataStore Task StoreFailedErrorImport(FailedErrorImport failure); Task CreateEditFailedMessageManager(); Task> GetFailureGroupView(string groupId, string status, string modified); - Task> GetFailureGroupsByClassifier(string classifier); + Task> GetFailureGroupsByClassifier(string classifier, AuthorizationInfo authInfo); // GetAllErrorsController - Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo); - Task ErrorsHead(string status, string modified, string queueAddress); - Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo); + Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo, AuthorizationInfo authInfo); + Task ErrorsHead(string status, string modified, string queueAddress, AuthorizationInfo authInfo); + Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo, AuthorizationInfo authInfo); Task> ErrorsSummary(); // GetErrorByIdController diff --git a/src/ServiceControl.Persistence/IGroupsDataStore.cs b/src/ServiceControl.Persistence/IGroupsDataStore.cs index 7f1fa1b02c..eea50e9fe6 100644 --- a/src/ServiceControl.Persistence/IGroupsDataStore.cs +++ b/src/ServiceControl.Persistence/IGroupsDataStore.cs @@ -2,11 +2,12 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; public interface IGroupsDataStore { - Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter); + Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter, AuthorizationInfo authInfo); Task GetCurrentForwardingBatch(); } } diff --git a/src/ServiceControl.Persistence/Infrastructure/AuthorizationInfo.cs b/src/ServiceControl.Persistence/Infrastructure/AuthorizationInfo.cs new file mode 100644 index 0000000000..39f3dfe93c --- /dev/null +++ b/src/ServiceControl.Persistence/Infrastructure/AuthorizationInfo.cs @@ -0,0 +1,57 @@ +namespace ServiceControl.Persistence.Infrastructure +{ + using System.Linq; + using System.Security.Claims; + using System.Text.Json; + using System.Text.RegularExpressions; + + public class AuthorizationInfo + { + public string[] ReadQueues { get; set; } + public string[] WriteQueues { get; set; } + public string[] HeaderFilters { get; set; } + + public bool IsQueueReadable(string queueAddress) + { + if (ReadQueues == null || ReadQueues.Length == 0 || ReadQueues.Contains("*")) + { + return true; + } + + var normalized = queueAddress?.ToLowerInvariant(); + foreach (var queue in ReadQueues) + { + if (queue.Contains('*')) + { + var pattern = "^" + string.Join(".*", queue.ToLowerInvariant().Split('*').Select(Regex.Escape)) + "$"; + if (Regex.IsMatch(normalized ?? string.Empty, pattern)) + { + return true; + } + } + else if (queue.ToLowerInvariant() == normalized) + { + return true; + } + } + + return false; + } + + public static AuthorizationInfo FromClaims(ClaimsPrincipal user) + { + user.FindFirst("ServicePlatformPermissions") + var value = user.FindFirst("CanReadFromQueues")?.Value; + var headerFiltersClaim = user.FindFirst("HeaderFilters")?.Value; + + //var headerFilters = JsonSerializer.Deserialize(headerFiltersClaim); + + var authInfo = new AuthorizationInfo(); + if (!string.IsNullOrEmpty(value)) + { + authInfo.ReadQueues = JsonSerializer.Deserialize(value); + } + return authInfo; + } + } +} \ No newline at end of file diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index bad1ec4cf5..bda4e9d297 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -6,6 +6,7 @@ namespace ServiceControl.MessageFailures.Api using InternalMessages; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using Persistence.Infrastructure; using ServiceControl.Persistence; using ServiceControl.Recoverability; @@ -38,7 +39,8 @@ public async Task ArchiveBatch(string[] messageIds) [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace") { - var results = await dataStore.GetFailureGroupsByClassifier(classifier); + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + var results = await dataStore.GetFailureGroupsByClassifier(classifier, authInfo); Response.WithDeterministicEtag(EtagHelper.CalculateEtag(results)); diff --git a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs index 60f9f08ca9..584cf386b2 100644 --- a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs @@ -15,12 +15,15 @@ public class GetAllErrorsController(IErrorMessageDataStore store) : ControllerBa [HttpGet] public async Task> ErrorsGet([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string queueAddress) { + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + var results = await store.ErrorGet( status: status, modified: modified, queueAddress: queueAddress, pagingInfo, - sortInfo + sortInfo, + authInfo ); Response.WithQueryStatsAndPagingInfo(results.QueryStats, pagingInfo); @@ -32,10 +35,13 @@ public async Task> ErrorsGet([FromQuery] PagingInfo pag [HttpHead] public async Task ErrorsHead(string status, string modified, string queueAddress) { + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + var queryResult = await store.ErrorsHead( status: status, modified: modified, - queueAddress: queueAddress + queueAddress: queueAddress, + authInfo ); Response.WithQueryStatsInfo(queryResult); @@ -45,12 +51,15 @@ public async Task ErrorsHead(string status, string modified, string queueAddress [HttpGet] public async Task> ErrorsByEndpointName([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string endpointName) { + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + var results = await store.ErrorsByEndpointName( status: status, endpointName: endpointName, modified: modified, pagingInfo, - sortInfo + sortInfo, + authInfo ); Response.WithQueryStatsAndPagingInfo(results.QueryStats, pagingInfo); diff --git a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs index 437b6fc5a3..07a89b1bfe 100644 --- a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs @@ -1,8 +1,10 @@ namespace ServiceControl.MessageFailures.Api { + using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Persistence; + using Persistence.Infrastructure; [ApiController] [Route("api")] @@ -13,8 +15,18 @@ public class GetErrorByIdController(IErrorMessageDataStore store) : ControllerBa public async Task> ErrorBy(string failedMessageId) { var result = await store.ErrorBy(failedMessageId); + if (result == null) + { + return NotFound(); + } - return result == null ? NotFound() : result; + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + if (!authInfo.IsQueueReadable(result.ProcessingAttempts.Last().FailureDetails.AddressOfFailingEndpoint)) + { + return NotFound(); + } + + return result; } [Route("errors/last/{failedMessageId:required:minlength(1)}")] @@ -22,8 +34,18 @@ public async Task> ErrorBy(string failedMessageId) public async Task> ErrorLastBy(string failedMessageId) { var result = await store.ErrorLastBy(failedMessageId); + if (result == null) + { + return NotFound(); + } + + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + if (!authInfo.IsQueueReadable(result.QueueAddress)) + { + return NotFound(); + } - return result == null ? NotFound() : result; + return result; } } } \ No newline at end of file diff --git a/src/ServiceControl/Properties/launchSettings.json b/src/ServiceControl/Properties/launchSettings.json index e9f96331bf..be3b4c4803 100644 --- a/src/ServiceControl/Properties/launchSettings.json +++ b/src/ServiceControl/Properties/launchSettings.json @@ -4,7 +4,8 @@ "commandName": "Project", "launchBrowser": false, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://0.0.0.0:33333" } }, "Setup ServiceControl": { diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index b03b2b702d..de68f163ff 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -59,7 +59,9 @@ public async Task GetAllGroups(string classifier = "Exception classifierFilter = null; } - var results = await fetcher.GetGroups(classifier, classifierFilter); + var authInfo = AuthorizationInfo.FromClaims(HttpContext.User); + + var results = await fetcher.GetGroups(classifier, classifierFilter, authInfo); Response.WithDeterministicEtag(EtagHelper.CalculateEtag(results)); return results; } diff --git a/src/ServiceControl/Recoverability/API/GroupFetcher.cs b/src/ServiceControl/Recoverability/API/GroupFetcher.cs index a2e85058ab..49445df916 100644 --- a/src/ServiceControl/Recoverability/API/GroupFetcher.cs +++ b/src/ServiceControl/Recoverability/API/GroupFetcher.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using ServiceControl.Persistence; + using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.Recoverability; public class GroupFetcher @@ -16,9 +17,9 @@ public GroupFetcher(IGroupsDataStore store, IRetryHistoryDataStore retryStore, R this.archiver = archiver; } - public async Task GetGroups(string classifier, string classifierFilter) + public async Task GetGroups(string classifier, string classifierFilter, AuthorizationInfo authInfo) { - var dbGroups = await store.GetFailureGroupsByClassifier(classifier, classifierFilter); + var dbGroups = await store.GetFailureGroupsByClassifier(classifier, classifierFilter, authInfo); var retryHistory = await retryStore.GetRetryHistory(); var unacknowledgedRetries = retryHistory.GetUnacknowledgedByClassifier(classifier);