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
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.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.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
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..b24c73a7e8
--- /dev/null
+++ b/src/ServiceControl/Infrastructure/WebApi/MeController.cs
@@ -0,0 +1,93 @@
+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