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();