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