From 4dfbf23f1c1c7954509bde1b589b439fdd53de4a Mon Sep 17 00:00:00 2001 From: williambza Date: Tue, 16 Jun 2026 15:37:45 +0200 Subject: [PATCH 1/3] Add permissions endpoint --- src/ServiceControl.Api/Contracts/RootUrls.cs | 2 + .../Infrastructure/Api/ConfigurationApi.cs | 2 + .../Infrastructure/WebApi/MeController.cs | 91 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/ServiceControl/Infrastructure/WebApi/MeController.cs diff --git a/src/ServiceControl.Api/Contracts/RootUrls.cs b/src/ServiceControl.Api/Contracts/RootUrls.cs index f019ef249e..7bddd2a483 100644 --- a/src/ServiceControl.Api/Contracts/RootUrls.cs +++ b/src/ServiceControl.Api/Contracts/RootUrls.cs @@ -20,5 +20,7 @@ public class RootUrls public string EventLogItems { get; set; } public string ArchivedGroupsUrl { get; set; } public string GetArchiveGroup { get; set; } + public string MypermissionsAll { get; set; } + public string MypermissionsSummary { get; set; } } } diff --git a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs index 607013d677..3d24a1142b 100644 --- a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs +++ b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs @@ -43,6 +43,8 @@ public Task GetUrls(string baseUrl, CancellationToken cancellationToke EventLogItems = baseUrl + "eventlogitems", ArchivedGroupsUrl = baseUrl + "errors/groups/{classifier?}", GetArchiveGroup = baseUrl + "archive/groups/id/{groupId}", + MypermissionsAll = baseUrl + "my/permissions/all", + MypermissionsSummary = baseUrl + "my/permissions" }; return Task.FromResult(model); diff --git a/src/ServiceControl/Infrastructure/WebApi/MeController.cs b/src/ServiceControl/Infrastructure/WebApi/MeController.cs new file mode 100644 index 0000000000..e4f07b319f --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/MeController.cs @@ -0,0 +1,91 @@ +namespace ServiceControl.Infrastructure.WebApi +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Auth; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using ServiceBus.Management.Infrastructure.Settings; + + [ApiController] + [Route("api")] + [Authorize] + public class MeController(Settings settings) : ControllerBase + { + [HttpGet] + [Route("my/permissions/all")] + public ActionResult GetMyPermissions() + { + var descriptor = new PermissionsDescriptor( + User.FindFirst("sub")?.Value ?? User.Identity?.Name ?? "unknown", + GrantedPermissions().OrderBy(p => p, StringComparer.Ordinal).ToList()); + + return Ok(descriptor); + } + + [HttpGet] + [Route("my/permissions")] + public ActionResult GetSummaryPermissions() + { + var granted = GrantedPermissions(); + + var summary = new PermissionsSummary( + FailedMessagesRead: HasView(granted, IsFailedMessagesResource), + FailedMessagesWrite: HasWrite(granted, IsFailedMessagesResource), + AuditingRead: HasView(granted, IsAuditResource), + MonitoringRead: HasView(granted, IsMonitoringResource), + MonitoringWrite: HasWrite(granted, IsMonitoringResource), + AdminRead: HasView(granted, IsAdminResource), + AdminWrite: HasWrite(granted, IsAdminResource)); + + return Ok(summary); + } + + // The set of permissions the current user holds, taking the RBAC-disabled "allow everything" + // mode (mirrors PermissionPolicyProvider's allow-all policy) into account. + IReadOnlySet GrantedPermissions() + { + var oidc = settings.OpenIdConnectSettings; + + return oidc.RoleBasedAuthorizationEnabled + ? RolePermissions.GetPermissions(User.FindAll(oidc.RolesClaim).Select(c => c.Value)) + : Permissions.All; + } + + static bool HasView(IEnumerable permissions, Func inGroup) => + permissions.Any(p => inGroup(p) && p.EndsWith(":view", StringComparison.Ordinal)); + + static bool HasWrite(IEnumerable permissions, Func inGroup) => + permissions.Any(p => inGroup(p) && !p.EndsWith(":view", StringComparison.Ordinal)); + + // Resources within the "error" instance that belong to the settings/admin area rather than the + // failure-triage area. Everything else under "error:" (messages, recoverability groups, endpoints, + // heartbeats, custom checks, sagas, event log, queues) is one bundle: a user either has access to + // all of failure-triage, or none of it. + static readonly string[] AdminResources = ["licensing", "notifications", "redirects", "throughput"]; + + static bool IsAdminResource(string permission) => + AdminResources.Any(resource => permission.StartsWith($"error:{resource}:", StringComparison.Ordinal)); + + static bool IsFailedMessagesResource(string permission) => + permission.StartsWith("error:", StringComparison.Ordinal) && !IsAdminResource(permission); + + static bool IsAuditResource(string permission) => + permission.StartsWith("audit:", StringComparison.Ordinal); + + static bool IsMonitoringResource(string permission) => + permission.StartsWith("monitoring:", StringComparison.Ordinal); + + public sealed record PermissionsDescriptor(string User, IReadOnlyList Permissions); + + public sealed record PermissionsSummary( + bool FailedMessagesRead, + bool FailedMessagesWrite, + bool AuditingRead, + bool MonitoringRead, + bool MonitoringWrite, + bool AdminRead, + bool AdminWrite); + } +} \ No newline at end of file From 9effd73f17f39b0bf058f7e74f72aa7fe59b08fd Mon Sep 17 00:00:00 2001 From: williambza Date: Tue, 16 Jun 2026 18:38:29 +0200 Subject: [PATCH 2/3] Add endpoints to show user permissions --- .../When_my_permissions_are_requested.cs | 167 ++++++++++++++++++ ...en_role_based_authorization_is_disabled.cs | 99 +++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs new file mode 100644 index 0000000000..e57f3241ec --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs @@ -0,0 +1,167 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect +{ + using System.Net.Http; + using System.Security.Claims; + using System.Text.Json; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using Infrastructure.Auth; + using Infrastructure.WebApi; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// my/permissions and my/permissions/all + /// These endpoints let a client (e.g. ServicePulse) discover what the current user is allowed to + /// do: the full granular permission list, and a simplified per-area summary used to gate UI + /// sections. Both are governed by the caller's role claims via . + /// + class When_my_permissions_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/permissions/all"); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertUnauthorized(response); + } + + [Test] + public async Task Should_return_only_the_permissions_granted_to_the_reader_role() + { + var descriptor = await GetPermissions(RolePermissions.Reader); + + Assert.That(descriptor.Permissions, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader))); + } + + [Test] + public async Task Should_return_every_known_permission_for_the_writer_role() + { + var descriptor = await GetPermissions(RolePermissions.Writer); + + Assert.That(descriptor.Permissions, Is.EquivalentTo(Permissions.All)); + } + + [Test] + public async Task Should_summarize_the_reader_role_as_read_only() + { + var summary = await GetSummary(RolePermissions.Reader); + + using (Assert.EnterMultipleScope()) + { + Assert.That(summary.FailedMessagesRead, Is.True); + Assert.That(summary.FailedMessagesWrite, Is.False); + Assert.That(summary.AuditingRead, Is.True); + Assert.That(summary.MonitoringRead, Is.True); + Assert.That(summary.MonitoringWrite, Is.False); + Assert.That(summary.AdminRead, Is.True); + Assert.That(summary.AdminWrite, Is.False); + } + } + + [Test] + public async Task Should_summarize_the_writer_role_as_full_access() + { + var summary = await GetSummary(RolePermissions.Writer); + + using (Assert.EnterMultipleScope()) + { + Assert.That(summary.FailedMessagesRead, Is.True); + Assert.That(summary.FailedMessagesWrite, Is.True); + Assert.That(summary.AuditingRead, Is.True); + Assert.That(summary.MonitoringRead, Is.True); + Assert.That(summary.MonitoringWrite, Is.True); + Assert.That(summary.AdminRead, Is.True); + Assert.That(summary.AdminWrite, Is.True); + } + } + + [Test] + public async Task Should_summarize_a_role_with_no_grants_as_all_false() + { + // A role that exists on the token but isn't recognised by RolePermissions grants nothing. + var summary = await GetSummary("not-a-real-role"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(summary.FailedMessagesRead, Is.False); + Assert.That(summary.FailedMessagesWrite, Is.False); + Assert.That(summary.AuditingRead, Is.False); + Assert.That(summary.MonitoringRead, Is.False); + Assert.That(summary.MonitoringWrite, Is.False); + Assert.That(summary.AdminRead, Is.False); + Assert.That(summary.AdminWrite, Is.False); + } + } + + async Task GetPermissions(string role) => + await Get("/api/my/permissions/all", role); + + async Task GetSummary(string role) => + await Get("/api/my/permissions", role); + + async Task Get(string path, string role) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, + HttpMethod.Get, + path, + token); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertAuthenticated(response); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, SerializerOptions); + } + + class Context : ScenarioContext; + } +} \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs new file mode 100644 index 0000000000..6757a56e3e --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs @@ -0,0 +1,99 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect +{ + using System.Net.Http; + using System.Text.Json; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using Infrastructure.Auth; + using Infrastructure.WebApi; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// When the caller is authenticated but Authentication.RoleBasedAuthorizationEnabled is false (the + /// default), every permission is implicitly granted (mirrors PermissionPolicyProvider's allow-all + /// policy). my/permissions and my/permissions/all should reflect that even for a token that carries + /// no "roles" claim at all. + /// + class When_role_based_authorization_is_disabled : 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() + // Role-based authorization deliberately left disabled (the default). + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + [Test] + public async Task Should_grant_every_known_permission_regardless_of_roles() + { + var descriptor = await Get("/api/my/permissions/all"); + + Assert.That(descriptor.Permissions, Is.EquivalentTo(Permissions.All)); + } + + [Test] + public async Task Should_summarize_as_full_access_regardless_of_roles() + { + var summary = await Get("/api/my/permissions"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(summary.FailedMessagesRead, Is.True); + Assert.That(summary.FailedMessagesWrite, Is.True); + Assert.That(summary.AuditingRead, Is.True); + Assert.That(summary.MonitoringRead, Is.True); + Assert.That(summary.MonitoringWrite, Is.True); + Assert.That(summary.AdminRead, Is.True); + Assert.That(summary.AdminWrite, Is.True); + } + } + + async Task Get(string path) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + // No "roles" claim at all - allow-all must not depend on the token carrying any role. + var token = mockOidcServer.GenerateToken(); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, + HttpMethod.Get, + path, + token); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertAuthenticated(response); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, SerializerOptions); + } + + class Context : ScenarioContext; + } +} \ No newline at end of file From 8b27f70c3cd165bda62390ca2175bec19c59c848 Mon Sep 17 00:00:00 2001 From: williambza Date: Tue, 16 Jun 2026 19:04:01 +0200 Subject: [PATCH 3/3] Fix approvals --- .../ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt | 2 ++ .../ApprovalFiles/APIApprovals.RootPathValue.approved.txt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 9b088fad11..32a038ce95 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -47,6 +47,8 @@ GET /messages/{id}/body => ServiceControl.CompositeViews.Messages.GetMessagesCon GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q) GET /messages/search/{keyword} => ServiceControl.CompositeViews.Messages.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword) GET /messages2 => ServiceControl.CompositeViews.Messages.GetMessages2Controller:Messages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q) +GET /my/permissions => ServiceControl.Infrastructure.WebApi.MeController:GetSummaryPermissions() +GET /my/permissions/all => ServiceControl.Infrastructure.WebApi.MeController:GetMyPermissions() GET /notifications/email => ServiceControl.Notifications.Api.NotificationsController:GetEmailNotificationsSettings() POST /notifications/email => ServiceControl.Notifications.Api.NotificationsController:UpdateSettings(UpdateEmailNotificationsSettingsRequest request) POST /notifications/email/test => ServiceControl.Notifications.Api.NotificationsController:SendTestEmail() diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt index c211707591..05220c3de3 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt @@ -16,5 +16,7 @@ "SagasUrl": "http://localhost/sagas", "EventLogItems": "http://localhost/eventlogitems", "ArchivedGroupsUrl": "http://localhost/errors/groups/{classifier?}", - "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}" + "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}", + "MypermissionsAll": "http://localhost/my/permissions/all", + "MypermissionsSummary": "http://localhost/my/permissions" } \ No newline at end of file