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.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/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/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/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.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 9b088fad11..5a0176d2f5 100644
--- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
+++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt
@@ -47,6 +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/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/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();