From 4dfbf23f1c1c7954509bde1b589b439fdd53de4a Mon Sep 17 00:00:00 2001 From: williambza Date: Tue, 16 Jun 2026 15:37:45 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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 From dc2a05ab5be368f23fd78c55742eb96605fbe2b7 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 19 Jun 2026 10:54:19 +0200 Subject: [PATCH 4/8] Show username instead of ID --- .../Auth/ClaimsPrinicpalExtensionMethods.cs | 20 +++++++++++++++++++ .../Auth/PermissionVerbHandler.cs | 18 +++-------------- .../Infrastructure/WebApi/MeController.cs | 5 +++-- 3 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs 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/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs index 6d3036f59b..138827331f 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -37,8 +37,8 @@ protected override Task HandleRequirementAsync( return Task.CompletedTask; } - var subjectId = RequireClaim(context.User, oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); - var subjectName = RequireClaim(context.User, oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); + var subjectId = context.User.RequireClaim(oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim"); + var subjectName = context.User.RequireClaim(oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim"); var roles = context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToArray(); var permission = requirement.Permission; @@ -71,16 +71,4 @@ protected override Task HandleRequirementAsync( // Leave the requirement unmet → the framework forbids (403). return Task.CompletedTask; } - - static string RequireClaim(ClaimsPrincipal user, string claimType, string settingName) - { - var value = user.FindFirst(claimType)?.Value; - if (string.IsNullOrEmpty(value)) - { - throw new InvalidOperationException( - $"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " + - "Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits."); - } - return value; - } -} +} \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/WebApi/MeController.cs b/src/ServiceControl/Infrastructure/WebApi/MeController.cs index e4f07b319f..e040d258a9 100644 --- a/src/ServiceControl/Infrastructure/WebApi/MeController.cs +++ b/src/ServiceControl/Infrastructure/WebApi/MeController.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Infrastructure.WebApi using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Hosting.Auth; [ApiController] [Route("api")] @@ -15,10 +16,10 @@ public class MeController(Settings settings) : ControllerBase { [HttpGet] [Route("my/permissions/all")] - public ActionResult GetMyPermissions() + public ActionResult GetAllMyPermissions() { var descriptor = new PermissionsDescriptor( - User.FindFirst("sub")?.Value ?? User.Identity?.Name ?? "unknown", + HttpContext.User.RequireClaim(settings.OpenIdConnectSettings.SubjectNameClaim, "Authentication.SubjectIdClaim"), GrantedPermissions().OrderBy(p => p, StringComparer.Ordinal).ToList()); return Ok(descriptor); From c298a709f5fd53a8d087d9ef7b1d35086f2ae668 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 19 Jun 2026 10:58:24 +0200 Subject: [PATCH 5/8] Use IReadOnlySet instead of List --- src/ServiceControl/Infrastructure/WebApi/MeController.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl/Infrastructure/WebApi/MeController.cs b/src/ServiceControl/Infrastructure/WebApi/MeController.cs index e040d258a9..b24c73a7e8 100644 --- a/src/ServiceControl/Infrastructure/WebApi/MeController.cs +++ b/src/ServiceControl/Infrastructure/WebApi/MeController.cs @@ -20,7 +20,8 @@ public ActionResult GetAllMyPermissions() { var descriptor = new PermissionsDescriptor( HttpContext.User.RequireClaim(settings.OpenIdConnectSettings.SubjectNameClaim, "Authentication.SubjectIdClaim"), - GrantedPermissions().OrderBy(p => p, StringComparer.Ordinal).ToList()); + GrantedPermissions() + ); return Ok(descriptor); } @@ -78,7 +79,7 @@ static bool IsAuditResource(string permission) => static bool IsMonitoringResource(string permission) => permission.StartsWith("monitoring:", StringComparison.Ordinal); - public sealed record PermissionsDescriptor(string User, IReadOnlyList Permissions); + public sealed record PermissionsDescriptor(string User, IReadOnlySet Permissions); public sealed record PermissionsSummary( bool FailedMessagesRead, From 303003809f549cd6c28ac7cb73c95d13bb2ffb7c Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 22 Jun 2026 11:02:49 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20Update=20HttpApiRoutes=20app?= =?UTF-8?q?roval=20for=20GetAllMyPermissions=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 32a038ce95..664beb3eef 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -48,7 +48,7 @@ GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesContro 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 /my/permissions/all => ServiceControl.Infrastructure.WebApi.MeController:GetAllMyPermissions() 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() From 3218b8b1bb5595ce00517f02cd719543023b3dcb Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 22 Jun 2026 11:20:42 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20Deserialize=20permissions=20?= =?UTF-8?q?response=20into=20a=20concrete=20HashSet=20in=20OIDC=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/my/permissions/all endpoint serializes PermissionsDescriptor whose Permissions property is IReadOnlySet. System.Text.Json cannot deserialize into an interface, so the acceptance tests that read the response back threw NotSupportedException. Introduce a shared PermissionsResponse DTO with a concrete HashSet and use it from both When_my_permissions_are_requested and When_role_based_authorization_is_disabled. The production API contract (IReadOnlySet) is unchanged. --- .../Security/OpenIdConnect/PermissionsResponse.cs | 10 ++++++++++ .../OpenIdConnect/When_my_permissions_are_requested.cs | 4 ++-- .../When_role_based_authorization_is_disabled.cs | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs new file mode 100644 index 0000000000..442751aa56 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect +{ + using System.Collections.Generic; + + // Deserialization target for the my/permissions/all response. The controller's + // PermissionsDescriptor exposes Permissions as IReadOnlySet, which System.Text.Json + // cannot deserialize (it can't instantiate an interface). The serialized JSON is a plain array, + // so a concrete HashSet round-trips it. + record PermissionsResponse(string User, HashSet Permissions); +} 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 index e57f3241ec..512859fd0e 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs @@ -133,8 +133,8 @@ public async Task Should_summarize_a_role_with_no_grants_as_all_false() } } - async Task GetPermissions(string role) => - await Get("/api/my/permissions/all", role); + async Task GetPermissions(string role) => + await Get("/api/my/permissions/all", role); async Task GetSummary(string role) => await Get("/api/my/permissions", role); 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 index 6757a56e3e..2a102baa7d 100644 --- 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 @@ -48,7 +48,7 @@ public void CleanupAuth() [Test] public async Task Should_grant_every_known_permission_regardless_of_roles() { - var descriptor = await Get("/api/my/permissions/all"); + var descriptor = await Get("/api/my/permissions/all"); Assert.That(descriptor.Permissions, Is.EquivalentTo(Permissions.All)); } From ecd3e0ebd8d3b7de55d5a631184b63e059b00ce0 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 26 Jun 2026 11:25:53 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20Replace=20my/permissions=20with?= =?UTF-8?q?=20a=20my/routes=20allowed-route=20manifest=20(#5556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of leaking internal permission strings to ServicePulse, expose the concrete set of API routes the caller is allowed to reach, so the UI gates on a stable route contract rather than coupling to the permission catalogue. - Project the ASP.NET EndpointDataSource into a route⇒permission table and resolve per-request effective permissions, filtering the manifest to the routes the caller can reach. - Add the my/routes controller (served by every instance), route template normalization, and the manifest DTOs. - Remove the my/permissions endpoint, MeController, PermissionsResponse and the root-doc permission fields. - Add an admin role (read-all + manage config/admin-area resources, no message-triage write actions), sitting between reader and writer. - Acceptance, infrastructure and approval tests for the new surface, plus an anti-drift policy guard. --- .../OpenIdConnect/PermissionsResponse.cs | 10 -- .../When_my_permissions_are_requested.cs | 167 ------------------ .../When_my_routes_are_requested.cs | 110 ++++++++++++ ...en_role_based_authorization_is_disabled.cs | 99 ----------- src/ServiceControl.Api/Contracts/RootUrls.cs | 2 - .../API/APIApprovals.cs | 46 ++++- .../APIApprovals.HttpApiRoutes.approved.txt | 1 + .../HostApplicationBuilderExtensions.cs | 1 + .../Auth/MyRoutesController.cs | 29 +++ .../Auth/PermissionAuthorizationExtensions.cs | 4 + .../Auth/RouteAuthorizationTable.cs | 57 ++++++ .../Auth/EffectivePermissionsTests.cs | 46 +++++ .../Auth/RouteManifestFilterTests.cs | 39 ++++ .../Auth/RouteTemplateNormalizerTests.cs | 21 +++ .../Auth/EffectivePermissions.cs | 26 +++ .../Auth/RolePermissions.cs | 16 ++ .../Auth/RouteManifest.cs | 51 ++++++ .../API/APIApprovals.cs | 46 ++++- .../APIApprovals.HttpApiRoutes.approved.txt | 1 + .../HostApplicationBuilderExtensions.cs | 1 + .../API/APIApprovals.cs | 46 ++++- .../APIApprovals.HttpApiRoutes.approved.txt | 3 +- .../APIApprovals.RootPathValue.approved.txt | 4 +- .../Infrastructure/Api/ConfigurationApi.cs | 2 - .../HostApplicationBuilderExtensions.cs | 1 + .../Infrastructure/WebApi/MeController.cs | 93 ---------- 26 files changed, 541 insertions(+), 381 deletions(-) delete mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs delete mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs create mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs delete mode 100644 src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs create mode 100644 src/ServiceControl.Hosting/Auth/MyRoutesController.cs create mode 100644 src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs create mode 100644 src/ServiceControl.Infrastructure/Auth/RouteManifest.cs delete mode 100644 src/ServiceControl/Infrastructure/WebApi/MeController.cs diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs deleted file mode 100644 index 442751aa56..0000000000 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/PermissionsResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect -{ - using System.Collections.Generic; - - // Deserialization target for the my/permissions/all response. The controller's - // PermissionsDescriptor exposes Permissions as IReadOnlySet, which System.Text.Json - // cannot deserialize (it can't instantiate an interface). The serialized JSON is a plain array, - // so a concrete HashSet round-trips it. - record PermissionsResponse(string User, HashSet Permissions); -} 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 deleted file mode 100644 index 512859fd0e..0000000000 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_permissions_are_requested.cs +++ /dev/null @@ -1,167 +0,0 @@ -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_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs new file mode 100644 index 0000000000..2093227e72 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -0,0 +1,110 @@ +namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect; + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using AcceptanceTesting; +using AcceptanceTesting.OpenIdConnect; +using NServiceBus.AcceptanceTesting; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +/// +/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries. +/// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it +/// already calls rather than on the server's internal permission vocabulary. +/// +class When_my_routes_are_requested : AcceptanceTest +{ + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + const string TestAudience = "api://test-audience"; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + [Test] + public async Task Should_reject_requests_without_bearer_token() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, HttpMethod.Get, "/api/my/routes"); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertUnauthorized(response); + } + + [Test] + public async Task Reader_can_view_but_cannot_retry() + { + var routes = await GetRoutes(RolePermissions.Reader); + + using (Assert.EnterMultipleScope()) + { + Assert.That(routes.Any(r => r.UrlTemplate == "/api/configuration"), Is.True, + "reader holds :view permissions, so view routes are allowed"); + Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.False, + "reader has no retry permission, so retry routes are excluded"); + } + } + + [Test] + public async Task Writer_can_retry() + { + var routes = await GetRoutes(RolePermissions.Writer); + + Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.True, + "writer holds every permission, so retry routes are allowed"); + } + + async Task> GetRoutes(string role) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, HttpMethod.Get, "/api/my/routes", token); + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertAuthenticated(response); + + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize>(content, SerializerOptions); + } + + class Context : ScenarioContext; +} diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs deleted file mode 100644 index 2a102baa7d..0000000000 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_role_based_authorization_is_disabled.cs +++ /dev/null @@ -1,99 +0,0 @@ -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 diff --git a/src/ServiceControl.Api/Contracts/RootUrls.cs b/src/ServiceControl.Api/Contracts/RootUrls.cs index 7bddd2a483..f019ef249e 100644 --- a/src/ServiceControl.Api/Contracts/RootUrls.cs +++ b/src/ServiceControl.Api/Contracts/RootUrls.cs @@ -20,7 +20,5 @@ 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.Audit.UnitTests/API/APIApprovals.cs b/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs index 60663693db..78e39fe0eb 100644 --- a/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs @@ -7,6 +7,7 @@ using System.Text; using Audit.Infrastructure.Settings; using Audit.Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -14,6 +15,8 @@ using Microsoft.AspNetCore.Routing; using NUnit.Framework; using Particular.Approvals; + using ServiceControl.Hosting.Auth; + using ServiceControl.Infrastructure.Auth; [TestFixture] class APIApprovals @@ -84,7 +87,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -101,6 +106,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index c9303ead9c..bfcfbc190b 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -13,4 +13,5 @@ GET /messages/{id}/body => ServiceControl.Audit.Auditing.MessagesView.GetMessage GET /messages/search => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q, CancellationToken cancellationToken) GET /messages/search/{keyword} => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword, CancellationToken cancellationToken) GET /messages2 => ServiceControl.Audit.Auditing.MessagesView.GetMessages2Controller:GetAllMessages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q, CancellationToken cancellationToken) +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() GET /sagas/{id} => ServiceControl.Audit.SagaAudit.SagasController:Sagas(PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 638041d4b1..cb70f9aedf 100644 --- a/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Audit/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -24,6 +24,7 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde options.ModelBinderProviders.Insert(0, new SortInfoModelBindingProvider()); }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); } } diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs new file mode 100644 index 0000000000..28a6d36ec1 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -0,0 +1,29 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +/// +/// Returns the API routes the current token may call, as { method, urlTemplate } entries. +/// This is the per-instance authorization contract for clients (ServicePulse): each instance reports +/// only the routes it serves, so a client matches its outgoing request against the allowed set without +/// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that +/// contract, so it is reachable by any authenticated user ([Authorize], no specific permission). +/// +[ApiController] +[Route("api")] +[Authorize] +public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConnectSettings settings) : ControllerBase +{ + [HttpGet] + [Route("my/routes")] + public ActionResult> GetMyRoutes() + { + var effective = EffectivePermissions.ForUser(User, settings); + return Ok(RouteManifestFilter.Filter(table.Entries, effective)); + } +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs index 6e56aa6e89..534ac5d95b 100644 --- a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -49,5 +49,9 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h // injected OpenIdConnectSettings so the handler can match them on the principal. services.AddSingleton(); services.AddSingleton(); + + // Backs the my/routes manifest: a singleton table projected from the wired endpoints. Reuses + // the EndpointDataSource the framework registers, so it sees exactly the routes that are served. + services.AddSingleton(); } } diff --git a/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs b/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs new file mode 100644 index 0000000000..fc376d39d4 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs @@ -0,0 +1,57 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using ServiceControl.Infrastructure.Auth; + +/// +/// Projects the wired controller endpoints into the static route ⇒ permission table that backs +/// the my/routes manifest. Built once on first access (after endpoints are mapped) and cached +/// for the process lifetime — routes are compiled in and never change at runtime. Each endpoint +/// contributes one per HTTP method, carrying the policy name from its +/// [Authorize(Policy = …)] attribute (the permission), whether it is [AllowAnonymous], +/// and the normalized template. No-policy endpoints are authenticated-only, matching the +/// RequireAuthenticatedUser fallback policy. +/// +public sealed class RouteAuthorizationTable(EndpointDataSource endpointDataSource) +{ + readonly Lazy> entries = new(() => Build(endpointDataSource)); + + public IReadOnlyList Entries => entries.Value; + + static IReadOnlyList Build(EndpointDataSource endpointDataSource) + { + var result = new List(); + + foreach (var endpoint in endpointDataSource.Endpoints.OfType()) + { + // Only controller actions: skips the SignalR hub and other non-MVC endpoints. + if (endpoint.Metadata.GetMetadata() is null) + { + continue; + } + + var template = RouteTemplateNormalizer.Normalize(endpoint.RoutePattern.RawText ?? string.Empty); + var allowAnonymous = endpoint.Metadata.GetMetadata() is not null; + var requiredPermission = endpoint.Metadata + .GetOrderedMetadata() + .Select(authorize => authorize.Policy) + .FirstOrDefault(policy => !string.IsNullOrEmpty(policy)); + + var methods = endpoint.Metadata.GetMetadata()?.HttpMethods + ?? []; + + foreach (var method in methods) + { + result.Add(new RouteAuthInfo(method, template, requiredPermission, allowAnonymous)); + } + } + + return result; + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs new file mode 100644 index 0000000000..c4383e8dcb --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/EffectivePermissionsTests.cs @@ -0,0 +1,46 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System; +using System.Linq; +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class EffectivePermissionsTests +{ + static readonly SettingsRootNamespace TestNamespace = new("ServiceControl"); + + static ClaimsPrincipal PrincipalWithRoles(params string[] roles) => + new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "test")); + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null); + } + + [Test] + public void Rbac_enabled_returns_the_union_of_role_permissions() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true"); + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); + + var result = EffectivePermissions.ForUser(PrincipalWithRoles(RolePermissions.Reader), settings); + + Assert.That(result, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader))); + } + + [Test] + public void Rbac_disabled_returns_all_permissions() + { + var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false); + + var result = EffectivePermissions.ForUser(PrincipalWithRoles("anything"), settings); + + Assert.That(result, Is.EquivalentTo(Permissions.All)); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs new file mode 100644 index 0000000000..64323629ef --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestFilterTests.cs @@ -0,0 +1,39 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Collections.Generic; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteManifestFilterTests +{ + static readonly RouteAuthInfo Retry = new("POST", "/api/errors/{id}/retry", "error:messages:retry", false); + static readonly RouteAuthInfo Archive = new("POST", "/api/errors/{id}/archive", "error:messages:archive", false); + static readonly RouteAuthInfo Configuration = new("GET", "/api/configuration", null, false); + static readonly RouteAuthInfo Root = new("GET", "/api", null, true); + + [Test] + public void Includes_granted_permissioned_anonymous_and_authenticated_only_routes() + { + var routes = new[] { Retry, Archive, Configuration, Root }; + var effective = new HashSet { "error:messages:retry" }; + + var result = RouteManifestFilter.Filter(routes, effective); + + Assert.That(result, Is.EquivalentTo(new[] + { + new RouteManifestEntry("POST", "/api/errors/{id}/retry"), + new RouteManifestEntry("GET", "/api/configuration"), + new RouteManifestEntry("GET", "/api"), + })); + } + + [Test] + public void Excludes_permissioned_routes_not_in_the_effective_set() + { + var result = RouteManifestFilter.Filter(new[] { Archive }, new HashSet()); + + Assert.That(result, Is.Empty); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs new file mode 100644 index 0000000000..373bd4337d --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteTemplateNormalizerTests.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteTemplateNormalizerTests +{ + [TestCase("api/errors/{failedMessageId:required:minlength(1)}/retry", "/api/errors/{failedMessageId}/retry")] + [TestCase("api/configuration", "/api/configuration")] + [TestCase("api/customchecks/{id}", "/api/customchecks/{id}")] + [TestCase("api/errors/groups/{classifier?}", "/api/errors/groups/{classifier}")] + [TestCase("api/messages/{*catchAll}", "/api/messages/{catchAll}")] + [TestCase("api/my/routes", "/api/my/routes")] + [TestCase("/api/already/rooted", "/api/already/rooted")] + public void Strips_constraints_and_roots_the_template(string raw, string expected) + { + Assert.That(RouteTemplateNormalizer.Normalize(raw), Is.EqualTo(expected)); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs new file mode 100644 index 0000000000..1cff8bd5e6 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/EffectivePermissions.cs @@ -0,0 +1,26 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +/// +/// The set of permissions a principal effectively holds, computed per request. Mirrors the inputs the +/// enforcement handler uses: when role-based authorization is enabled, the union of the permissions +/// granted by the principal's claims (via ); +/// when it is disabled the platform runs allow-all, so every known permission is held. +/// +public static class EffectivePermissions +{ + public static IReadOnlySet ForUser(ClaimsPrincipal user, OpenIdConnectSettings settings) + { + if (!settings.RoleBasedAuthorizationEnabled) + { + return Permissions.All; + } + + var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value); + return RolePermissions.GetPermissions(roles); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs index 7375919263..bb79f98f02 100644 --- a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -26,11 +26,27 @@ public static class RolePermissions /// Full-access role: every permission. public const string Writer = "writer"; + /// + /// Platform-administrator role: read-only on everything, plus full management of the configuration / + /// admin-area resources (licensing, notifications, retry redirects, throughput, connections) — but + /// not the message-triage write actions (retry/edit/archive/restore). + /// + public const string Admin = "admin"; + // Source of truth: the wildcard pattern(s) each role grants. static readonly Dictionary RolePatterns = new(StringComparer.OrdinalIgnoreCase) { [Reader] = ["*:*:view"], [Writer] = ["*:*:*"], + [Admin] = + [ + "*:*:view", + "error:licensing:*", + "error:notifications:*", + "error:redirects:*", + "error:throughput:*", + "error:connections:*", + ], }; // Expanded once against the full permission catalogue: role -> concrete granted permissions. diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs new file mode 100644 index 0000000000..c697a056f2 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -0,0 +1,51 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +/// +/// Normalizes an ASP.NET route pattern's raw text into the template form ServicePulse matches its +/// outgoing requests against: inline constraints/defaults/optional markers and catch-all stars are +/// removed (parameter names kept), and a single leading slash is guaranteed. For example +/// api/errors/{id:required:minlength(1)}/retry/api/errors/{id}/retry. +/// +public static partial class RouteTemplateNormalizer +{ + public static string Normalize(string rawTemplate) + { + var stripped = ParameterToken().Replace(rawTemplate, "{${name}}"); + return stripped.StartsWith('/') ? stripped : "/" + stripped; + } + + // Matches a single route parameter token: optional catch-all star(s), the parameter name, then + // anything up to the closing brace (constraints, default value, optional marker). + [GeneratedRegex(@"\{\*{0,2}(?[A-Za-z0-9_]+)[^}]*\}")] + private static partial Regex ParameterToken(); +} + +/// A route the server hosts, with the authorization metadata read from its endpoint. +public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous); + +/// A single allowed-route entry returned to the client. +public sealed record RouteManifestEntry(string Method, string UrlTemplate); + +/// +/// Projects the route table down to the entries a caller may invoke. A route is included when it is +/// anonymous, requires only authentication (no specific permission), or its required permission is in +/// the caller's effective set. Enforcement and this projection read the same inputs, so the advertised +/// manifest cannot drift from what the server actually allows. +/// +public static class RouteManifestFilter +{ + public static IReadOnlyList Filter( + IEnumerable routes, + IReadOnlySet effectivePermissions) => + routes + .Where(route => route.AllowAnonymous + || route.RequiredPermission is null + || effectivePermissions.Contains(route.RequiredPermission)) + .Select(route => new RouteManifestEntry(route.Method, route.UrlTemplate)) + .ToList(); +} diff --git a/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs b/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs index 19dd4ce5f5..bcab962001 100644 --- a/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.Monitoring.UnitTests/API/APIApprovals.cs @@ -5,10 +5,13 @@ namespace ServiceControl.Monitoring.UnitTests.API; using System.Linq; using System.Reflection; using System.Text; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NUnit.Framework; using Particular.Approvals; +using ServiceControl.Hosting.Auth; +using ServiceControl.Infrastructure.Auth; [TestFixture] public class APIApprovals @@ -62,7 +65,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -79,6 +84,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index c744e9bc77..3ce592eb39 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -6,3 +6,4 @@ GET /monitored-endpoints => ServiceControl.Monitoring.Http.Diagrams.DiagramApiCo GET /monitored-endpoints/{endpointName} => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:GetSingleEndpointMetrics(String endpointName, Nullable history) GET /monitored-endpoints/disconnected => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:DisconnectedEndpointCount() DELETE /monitored-instance/{endpointName}/{instanceId} => ServiceControl.Monitoring.Http.Diagrams.DiagramApiController:DeleteEndpointInstance(String endpointName, String instanceId) +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() diff --git a/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 37399a80fd..7ae51ad477 100644 --- a/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Monitoring/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -17,6 +17,7 @@ public static void AddServiceControlMonitoringApi(this IHostApplicationBuilder h options.Filters.Add(); }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); } } \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/API/APIApprovals.cs b/src/ServiceControl.UnitTests/API/APIApprovals.cs index 1a75ce026f..04d17fc409 100644 --- a/src/ServiceControl.UnitTests/API/APIApprovals.cs +++ b/src/ServiceControl.UnitTests/API/APIApprovals.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using Api.Contracts; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -18,7 +19,9 @@ using Particular.Approvals; using Particular.ServiceControl.Licensing; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Hosting.Auth; using ServiceControl.Infrastructure.Api; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.WebApi; using ServiceControl.Monitoring.HeartbeatMonitoring; @@ -94,7 +97,9 @@ public void HttpApiRoutes() IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes() { - var controllers = typeof(Program).Assembly.GetTypes() + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); foreach (var type in controllers) @@ -111,6 +116,45 @@ public void HttpApiRoutes() } } + static IEnumerable GetControllerAssemblies() => + [ + typeof(Program).Assembly, + typeof(MyRoutesController).Assembly + ]; + + [Test] + public void Authorize_policies_are_known_permissions() + { + var controllers = GetControllerAssemblies() + .SelectMany(a => a.GetTypes()) + .Distinct() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t)); + + foreach (var type in controllers) + { + foreach (var att in type.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + + foreach (var method in type.GetMethods()) + { + foreach (var att in method.GetCustomAttributes()) + { + if (!string.IsNullOrEmpty(att.Policy)) + { + Assert.That(Permissions.All.Contains(att.Policy), Is.True, + $"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All."); + } + } + } + } + } + static string PrettyTypeName(Type t) { if (t.IsArray) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 664beb3eef..5a0176d2f5 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -47,8 +47,7 @@ GET /messages/{id}/body => ServiceControl.CompositeViews.Messages.GetMessagesCon GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q) GET /messages/search/{keyword} => ServiceControl.CompositeViews.Messages.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword) GET /messages2 => ServiceControl.CompositeViews.Messages.GetMessages2Controller:Messages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q) -GET /my/permissions => ServiceControl.Infrastructure.WebApi.MeController:GetSummaryPermissions() -GET /my/permissions/all => ServiceControl.Infrastructure.WebApi.MeController:GetAllMyPermissions() +GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes() GET /notifications/email => ServiceControl.Notifications.Api.NotificationsController:GetEmailNotificationsSettings() POST /notifications/email => ServiceControl.Notifications.Api.NotificationsController:UpdateSettings(UpdateEmailNotificationsSettingsRequest request) POST /notifications/email/test => ServiceControl.Notifications.Api.NotificationsController:SendTestEmail() diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt index 05220c3de3..c211707591 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.RootPathValue.approved.txt @@ -16,7 +16,5 @@ "SagasUrl": "http://localhost/sagas", "EventLogItems": "http://localhost/eventlogitems", "ArchivedGroupsUrl": "http://localhost/errors/groups/{classifier?}", - "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}", - "MypermissionsAll": "http://localhost/my/permissions/all", - "MypermissionsSummary": "http://localhost/my/permissions" + "GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}" } \ No newline at end of file diff --git a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs index 3d24a1142b..607013d677 100644 --- a/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs +++ b/src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs @@ -43,8 +43,6 @@ 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/HostApplicationBuilderExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs index 62eb5bdb21..aee6e80584 100644 --- a/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/Infrastructure/WebApi/HostApplicationBuilderExtensions.cs @@ -36,6 +36,7 @@ public static void AddServiceControlApi(this IHostApplicationBuilder builder, Co }); controllers.AddApplicationPart(Assembly.GetExecutingAssembly()); controllers.AddApplicationPart(typeof(LicensingController).Assembly); + controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly); controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults()); var signalR = builder.Services.AddSignalR(); diff --git a/src/ServiceControl/Infrastructure/WebApi/MeController.cs b/src/ServiceControl/Infrastructure/WebApi/MeController.cs deleted file mode 100644 index b24c73a7e8..0000000000 --- a/src/ServiceControl/Infrastructure/WebApi/MeController.cs +++ /dev/null @@ -1,93 +0,0 @@ -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; - using ServiceControl.Hosting.Auth; - - [ApiController] - [Route("api")] - [Authorize] - public class MeController(Settings settings) : ControllerBase - { - [HttpGet] - [Route("my/permissions/all")] - public ActionResult GetAllMyPermissions() - { - var descriptor = new PermissionsDescriptor( - HttpContext.User.RequireClaim(settings.OpenIdConnectSettings.SubjectNameClaim, "Authentication.SubjectIdClaim"), - GrantedPermissions() - ); - - 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, IReadOnlySet 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