diff --git a/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj b/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj index 4f87cd4a63..d1136120d9 100644 --- a/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj +++ b/src/Particular.LicensingComponent/Particular.LicensingComponent.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 0b094fbb94..f898c0cc72 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -5,10 +5,12 @@ using System.Text.Json; using System.Threading; using Contracts; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Particular.LicensingComponent.Report; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api/licensing")] @@ -19,6 +21,7 @@ public LicensingController(IThroughputCollector throughputCollector) this.throughputCollector = throughputCollector; } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("endpoints")] [HttpGet] public async Task> GetEndpointThroughput(CancellationToken cancellationToken) @@ -26,6 +29,7 @@ public async Task> GetEndpointThroughput(Cancell return await throughputCollector.GetThroughputSummary(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputManage)] [Route("endpoints/update")] [HttpPost] public async Task UpdateUserSelectionOnEndpointThroughput(List updateUserIndicators, CancellationToken cancellationToken) @@ -34,6 +38,7 @@ public async Task UpdateUserSelectionOnEndpointThroughput(List CanThroughputReportBeGenerated(CancellationToken cancellationToken) @@ -41,6 +46,7 @@ public async Task CanThroughputReportBeGenerated(Cancella return await throughputCollector.GetReportGenerationState(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("report/file")] [HttpGet] public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken) @@ -77,6 +83,7 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/info")] [HttpGet] public async Task GetThroughputSettingsInformation(CancellationToken cancellationToken) @@ -84,10 +91,12 @@ public async Task GetThroughputSettingsInformation return await throughputCollector.GetThroughputConnectionSettingsInformation(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/test")] [HttpGet] public async Task TestThroughputConnectionSettings(CancellationToken cancellationToken) => await throughputCollector.TestConnectionSettings(cancellationToken); + [Authorize(Policy = Permissions.ErrorThroughputView)] [Route("settings/masks")] [HttpGet] public async Task> GetMasks(CancellationToken cancellationToken) @@ -95,6 +104,7 @@ public async Task> GetMasks(CancellationToken cancellationToken) return await throughputCollector.GetReportMasks(cancellationToken); } + [Authorize(Policy = Permissions.ErrorThroughputManage)] [Route("settings/masks/update")] [HttpPost] public async Task UpdateMasks(List updateMasks, CancellationToken cancellationToken) diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs index dc056b05c3..3148b18275 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs @@ -34,6 +34,18 @@ public OpenIdConnectTestConfiguration WithAuthenticationDisabled() return this; } + /// + /// Enables role-based authorization. When on, controllers carrying + /// [Authorize(Policy = Permissions.X)] require the caller's "roles" claim to map to a + /// role that grants the permission via RolePermissions. When off, the policy provider + /// returns allow-all policies and any authenticated request reaches the controller. + /// + public OpenIdConnectTestConfiguration WithRoleBasedAuthorizationEnabled() + { + SetEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true"); + return this; + } + /// /// Disables settings validation. This allows testing with placeholder/fake OIDC settings. /// Should only be used in test scenarios where a real OIDC provider is not available. @@ -164,6 +176,7 @@ public void ClearConfiguration() ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES"); ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY"); + ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED"); ClearEnvironmentVariable("VALIDATECONFIG"); } diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index e19736ff17..425b8b6e92 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -35,6 +36,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithServicePulseClientId(TestClientId) @@ -124,7 +126,10 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including error:messages:view + // required by /api/errors. Without a role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 657a84244d..c3b87d68fd 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -123,6 +123,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControl(settings, configuration); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 9d57a41316..1891b545d3 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -32,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); @@ -92,7 +94,10 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including audit:message:view + // required by /api/messages. Without a role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index efcd99c0f6..53a548f038 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -120,6 +120,7 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem diff --git a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 83897faeba..c73fd28a46 100644 --- a/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Audit.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -14,7 +14,9 @@ "RequireHttpsMetadata": true, "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs index db187bcd77..5027f7f43f 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessages2Controller.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView; using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; +using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class GetMessages2Controller(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages2")] [HttpGet] public async Task> GetAllMessages( diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs index 7a81b7294d..e88d1fbb6f 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/GetMessagesController.cs @@ -9,11 +9,13 @@ namespace ServiceControl.Audit.Auditing.MessagesView using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class GetMessagesController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages")] [HttpGet] public async Task> GetAllMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, CancellationToken cancellationToken) @@ -23,6 +25,7 @@ public async Task> GetAllMessages([FromQuery] PagingInfo pag return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages")] [HttpGet] public async Task> GetEndpointMessages([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, [FromQuery(Name = "include_system_messages")] bool includeSystemMessages, string endpoint, CancellationToken cancellationToken) @@ -43,6 +46,7 @@ public async Task> GetEndpointAuditCounts([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/{id}/body")] [HttpGet] public async Task Get(string id, CancellationToken cancellationToken) @@ -69,6 +73,7 @@ public async Task Get(string id, CancellationToken cancellationTo return result.StringContent != null ? Content(result.StringContent, contentType) : File(result.StreamContent, contentType); } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string q, CancellationToken cancellationToken) @@ -78,6 +83,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string keyword, CancellationToken cancellationToken) @@ -87,6 +93,7 @@ public async Task> SearchByKeyWord([FromQuery] PagingInfo pa return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string q, CancellationToken cancellationToken) @@ -96,6 +103,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.AuditMessageView)] [Route("endpoints/{endpoint}/messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyword([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string endpoint, string keyword, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs b/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs index 4fcc04cd88..7ba20c1a11 100644 --- a/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs +++ b/src/ServiceControl.Audit/Auditing/MessagesView/MessagesConversationController.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Auditing.MessagesView using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class MessagesConversationController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditMessageView)] [Route("conversations/{conversationId}")] [HttpGet] public async Task> Get([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string conversationId, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/Connection/ConnectionController.cs b/src/ServiceControl.Audit/Connection/ConnectionController.cs index 75daeb593d..8d8d0fa4dc 100644 --- a/src/ServiceControl.Audit/Connection/ConnectionController.cs +++ b/src/ServiceControl.Audit/Connection/ConnectionController.cs @@ -2,7 +2,9 @@ namespace ServiceControl.Audit.Connection { using System.Text.Json; using Infrastructure.Settings; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] @@ -11,6 +13,7 @@ public class ConnectionController(Settings settings) : ControllerBase // This controller doesn't use the default serialization settings because // ServicePulse and the Platform Connector Plugin expect the connection // details the be serialized and formatted in a specific way + [Authorize(Policy = Permissions.AuditConnectionView)] [Route("connection")] [HttpGet] public IActionResult GetConnectionDetails() => diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs index 22e2fff776..2bfdb9c065 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs @@ -19,6 +19,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlAudit((_, __) => { diff --git a/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs b/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs index 2ed0b2a278..3b7fd5871b 100644 --- a/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs +++ b/src/ServiceControl.Audit/Monitoring/KnownEndpoints/KnownEndpointsController.cs @@ -5,13 +5,16 @@ namespace ServiceControl.Audit.Monitoring using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; [ApiController] [Route("api")] public class KnownEndpointsController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditEndpointView)] [Route("endpoints/known")] [HttpGet] public async Task> GetAll([FromQuery] PagingInfo pagingInfo, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Audit/SagaAudit/SagasController.cs b/src/ServiceControl.Audit/SagaAudit/SagasController.cs index cd2356784d..3d92d5f913 100644 --- a/src/ServiceControl.Audit/SagaAudit/SagasController.cs +++ b/src/ServiceControl.Audit/SagaAudit/SagasController.cs @@ -5,14 +5,17 @@ namespace ServiceControl.Audit.SagaAudit using System.Threading.Tasks; using Infrastructure; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth; using ServiceControl.SagaAudit; [ApiController] [Route("api")] public class SagasController(IAuditDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.AuditSagaView)] [Route("sagas/{id}")] [HttpGet] public async Task Sagas([FromQuery] PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken) diff --git a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs index f425e7afb2..eba714e32d 100644 --- a/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Auth/HostApplicationBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Text.Json; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -36,7 +37,8 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder ValidateLifetime = oidcSettings.ValidateLifetime, ValidateIssuerSigningKey = oidcSettings.ValidateIssuerSigningKey, ValidAudience = oidcSettings.Audience, - ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew + ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew + RoleClaimType = oidcSettings.RolesClaim }; options.RequireHttpsMetadata = oidcSettings.RequireHttpsMetadata; // Don't map inbound claims to legacy Microsoft claim types @@ -99,6 +101,12 @@ public static void AddServiceControlAuthentication(this IHostApplicationBuilder configure.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build()); + + // Normalise per-IdP role claim shapes (Keycloak's nested realm_access.roles, Entra app + // roles, Cognito groups) into canonical "roles" claims for the verb handler. The source + // path is configurable via Authentication.RolesClaim. + hostBuilder.Services.AddSingleton( + new RolesClaimsTransformation(oidcSettings.RolesClaim)); } static string GetErrorMessage(JwtBearerChallengeContext context) diff --git a/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs new file mode 100644 index 0000000000..82d6c3fcbc --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionAuthorizationExtensions.cs @@ -0,0 +1,42 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure; + +/// +/// Registers the permission-based policy authorization services: a dynamic +/// that resolves [Authorize(Policy = "<permission>")] +/// attributes, and — when OIDC is enabled — the that evaluates them +/// against the user's roles. +/// +/// The provider is registered unconditionally so the policy attributes resolve in every configuration +/// (without it, annotated endpoints fail with "AuthorizationPolicy not found"). When OIDC is disabled the +/// provider returns allow-all policies that carry no requirement, so the verb handler is not registered. +/// Wire this into every instance that hosts annotated controllers (Error, Audit, Monitoring). +/// +/// +public static class PermissionAuthorizationExtensions +{ + public static void AddServiceControlAuthorization(this IHostApplicationBuilder hostBuilder, OpenIdConnectSettings oidcSettings) + { + var services = hostBuilder.Services; + + // Ensure the authorization core services and options are present (idempotent). + services.AddAuthorization(); + + // The policy provider is registered UNCONDITIONALLY: every instance hosts controllers with + // [Authorize(Policy = Permissions.X)] attributes, and without a provider that knows those + // policy names ASP.NET throws "AuthorizationPolicy named '...' was not found" → 500 on every + // request to an annotated endpoint. When RBAC is disabled the provider returns allow-all + // policies (no requirement), so anonymous-to-the-policy calls pass through and the verb + // handler is unnecessary. + services.AddSingleton(sp => + new PermissionPolicyProvider(sp.GetRequiredService>(), oidcSettings)); + + services.AddSingleton(_ => new PermissionVerbHandler(oidcSettings.RolesClaim)); + } +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs new file mode 100644 index 0000000000..500e50c335 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionPolicyProvider.cs @@ -0,0 +1,67 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth; + +/// +/// A dynamic that resolves a verb-level authorization policy +/// for each known permission string (e.g. error:messages:retry). +/// +/// The set of valid policy names is known up front (), so every policy is +/// built once at construction into a . The framework +/// calls on every request to a protected endpoint, so this makes that call +/// an O(1) lookup with no per-request policy allocation. (Authorization policies and requirements are +/// immutable, so the prebuilt instances are safely shared across all requests.) +/// +/// +/// When OIDC is enabled each permission maps to a policy carrying a +/// (evaluated by ). When OIDC is disabled the platform runs +/// unauthenticated, so every permission maps to a shared allow-all policy — no requirement, no handler. +/// Unknown policy names resolve to ; the default and fallback policies are +/// delegated to the configured . +/// +/// +public sealed class PermissionPolicyProvider(IOptions authorizationOptions, OpenIdConnectSettings oidcSettings) + : IAuthorizationPolicyProvider +{ + // Carries no requirement, so it succeeds without any IAuthorizationHandler being registered. + static readonly AuthorizationPolicy AllowAll = + new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build(); + + readonly FrozenDictionary policies = BuildPolicies(oidcSettings); + + static FrozenDictionary BuildPolicies(OpenIdConnectSettings oidcSettings) => + Permissions.All.ToFrozenDictionary( + permission => permission, + permission => oidcSettings.RoleBasedAuthorizationEnabled + ? new AuthorizationPolicyBuilder() + // RequireAuthenticatedUser() must come first so an unauthenticated request fails as + // FailedAuthentication (→ 401 challenge) rather than FailedRequirements (→ 403 + // forbid). Without it, PermissionVerbHandler is reached for anonymous callers and a + // missing-roles outcome is classified as a forbidden permission failure. + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(permission)) + .Build() + : AllowAll, + StringComparer.Ordinal); + + public Task GetPolicyAsync(string policyName) => + Task.FromResult(policies.GetValueOrDefault(policyName)); + + public Task GetDefaultPolicyAsync() + { + var defaultPolicy = authorizationOptions.Value.DefaultPolicy + ?? new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); + return Task.FromResult(defaultPolicy); + } + + public Task GetFallbackPolicyAsync() + => Task.FromResult(authorizationOptions.Value.FallbackPolicy); +} diff --git a/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs b/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs new file mode 100644 index 0000000000..77039457b7 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionRequirement.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using Microsoft.AspNetCore.Authorization; + +/// +/// An that carries the permission string enforced by a +/// [Authorize(Policy = "<permission>")] attribute (e.g. error:messages:view). +/// Evaluated by . +/// +public sealed record PermissionRequirement(string Permission) : IAuthorizationRequirement; diff --git a/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs new file mode 100644 index 0000000000..68a56e29ba --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs @@ -0,0 +1,42 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using ServiceControl.Infrastructure.Auth; + +/// +/// Verb-level authorization handler for . It resolves the user's +/// roles and checks them against the hardcoded policy: the user must hold +/// a role (e.g. reader / writer) that grants the requested permission. +/// +/// Only registered — and only reached — when OIDC is enabled. When it is disabled, +/// returns an allow-all policy that carries no +/// , so this handler is not needed. +/// +/// +public sealed class PermissionVerbHandler : AuthorizationHandler +{ + public PermissionVerbHandler(string rolesClaimName) + { + RoleClaimType = rolesClaimName; + } + + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + var roles = context.User.FindAll(RoleClaimType).Select(claim => claim.Value); + + if (RolePermissions.IsGranted(roles, requirement.Permission)) + { + context.Succeed(requirement); + } + + // Otherwise leave the requirement unmet → the request is denied (403/401). + return Task.CompletedTask; + } + + internal string RoleClaimType = "roles"; +} \ No newline at end of file diff --git a/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs new file mode 100644 index 0000000000..2f59829edc --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RolesClaimsTransformation.cs @@ -0,0 +1,56 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using ServiceControl.Infrastructure.Auth; + +/// +/// Normalises per-IdP role claim shapes into a flat set of roles claims that +/// can read directly. The source path is configured via +/// Authentication.RolesClaim (default realm_access.roles — the Keycloak out-of-box +/// shape). Flat claim names work too (roles for Keycloak with a "User Realm Role" mapper or +/// Microsoft Entra ID app roles, cognito:groups for AWS Cognito). +/// +/// ASP.NET may invoke multiple times for the same principal; a sentinel +/// claim makes the transformation idempotent and returns the same principal on subsequent calls. +/// +/// +public sealed class RolesClaimsTransformation(string rolesClaimPath) : IClaimsTransformation +{ + const string SentinelClaimType = "_roles_transformed"; + // The sentinel's value is irrelevant; only the claim's presence matters. A non-empty + // placeholder is required because a Claim value cannot be null. + const string SentinelClaimValue = "1"; + const string RoleClaimType = "roles"; + + public Task TransformAsync(ClaimsPrincipal principal) + { + var isAuthenticated = principal.Identity?.IsAuthenticated == true; + if (!isAuthenticated || AlreadyTransformed(principal)) + { + return Task.FromResult(principal); + } + + var roles = RolesClaimExtractor.Extract(principal, rolesClaimPath); + + var claims = new Claim[roles.Count + 1]; + claims[0] = new Claim(SentinelClaimType, SentinelClaimValue); + for (var i = 0; i < roles.Count; i++) + { + claims[i + 1] = new Claim(RoleClaimType, roles[i]); + } + + // Build a new principal so the original (cached) instance is left untouched. + var transformed = new ClaimsPrincipal(principal.Identities.ToArray()); + transformed.AddIdentity(new ClaimsIdentity(claims)); + return Task.FromResult(transformed); + } + + // True once this transformation has stamped its sentinel claim, keeping TransformAsync + // idempotent across the repeated calls ASP.NET makes for the same principal. + static bool AlreadyTransformed(ClaimsPrincipal principal) => + principal.HasClaim(SentinelClaimType, SentinelClaimValue); +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs new file mode 100644 index 0000000000..42f2387515 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RolesClaimExtractorTests.cs @@ -0,0 +1,124 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +public class RolesClaimExtractorTests +{ + [Test] + public void Flat_claim_with_repeated_string_values_returns_each_value() + { + var principal = PrincipalWith( + new Claim("roles", "operator"), + new Claim("roles", "viewer")); + + var result = RolesClaimExtractor.Extract(principal, "roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "operator", "viewer" })); + } + + [Test] + public void Flat_claim_serialized_as_json_array_string_is_decoded() + { + var principal = PrincipalWith(new Claim("roles", "[\"admin\",\"writer\"]")); + + var result = RolesClaimExtractor.Extract(principal, "roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "admin", "writer" })); + } + + [Test] + public void Nested_keycloak_path_extracts_realm_access_roles() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"roles\":[\"sc-admin\",\"sc-operator\"]}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "sc-admin", "sc-operator" })); + } + + [Test] + public void Nested_path_with_single_string_value_returns_one_value() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"role\":\"sc-admin\"}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.role"); + + Assert.That(result, Is.EqualTo(new[] { "sc-admin" })); + } + + [Test] + public void Missing_top_level_claim_returns_empty() + { + var principal = PrincipalWith(new Claim("other", "anything")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Missing_nested_property_returns_empty() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"resource_access\":{}}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Malformed_json_in_nested_claim_returns_empty() + { + var principal = PrincipalWith(new Claim("realm_access", "not json")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Empty_or_whitespace_path_returns_empty() + { + var principal = PrincipalWith(new Claim("roles", "viewer")); + + Assert.That(RolesClaimExtractor.Extract(principal, ""), Is.Empty); + Assert.That(RolesClaimExtractor.Extract(principal, " "), Is.Empty); + } + + [Test] + public void Non_string_array_entries_are_skipped() + { + var principal = PrincipalWith(new Claim( + "realm_access", + "{\"roles\":[\"valid\",42,null,\"alsovalid\"]}")); + + var result = RolesClaimExtractor.Extract(principal, "realm_access.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "valid", "alsovalid" })); + } + + [Test] + public void Multiple_top_level_claims_with_dotted_path_aggregate_values() + { + var principal = PrincipalWith( + new Claim("resource_access", "{\"client-a\":{\"roles\":[\"role-a\"]}}"), + new Claim("resource_access", "{\"client-a\":{\"roles\":[\"role-b\"]}}")); + + var result = RolesClaimExtractor.Extract(principal, "resource_access.client-a.roles"); + + Assert.That(result, Is.EquivalentTo(new[] { "role-a", "role-b" })); + } + + static ClaimsPrincipal PrincipalWith(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "Test")); +} diff --git a/src/ServiceControl.Infrastructure/Auth/Permissions.cs b/src/ServiceControl.Infrastructure/Auth/Permissions.cs new file mode 100644 index 0000000000..006fccf0f5 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Permissions.cs @@ -0,0 +1,137 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System.Collections.Generic; +using System.Reflection; + +/// +/// Catalogue of all known permission constants in the format instance:resource:action. +/// Each ServiceControl instance (error/audit/monitoring) is a separate process and namespaces its +/// permissions with an instance prefix. +/// +/// The set is automatically derived from all public const string +/// fields on this class, so adding a new constant is sufficient — no separate registration needed. +/// +/// +public static class Permissions +{ + // ───────────────────────────── Error instance (Primary) ───────────────────────────── + + /// Messages area — viewing, retrying, archiving, and editing failed messages. + public const string ErrorMessagesView = "error:messages:view"; + /// + public const string ErrorMessagesRetry = "error:messages:retry"; + /// + public const string ErrorMessagesArchive = "error:messages:archive"; + /// + public const string ErrorMessagesUnarchive = "error:messages:unarchive"; + /// + public const string ErrorMessagesEdit = "error:messages:edit"; + + /// Recoverability groups area — viewing, retrying, archiving, and unarchiving failure groups. + public const string ErrorRecoverabilityGroupsView = "error:recoverabilitygroups:view"; + /// + public const string ErrorRecoverabilityGroupsRetry = "error:recoverabilitygroups:retry"; + /// + public const string ErrorRecoverabilityGroupsArchive = "error:recoverabilitygroups:archive"; + /// + public const string ErrorRecoverabilityGroupsUnarchive = "error:recoverabilitygroups:unarchive"; + + /// Endpoints area — viewing, managing, and deleting monitored endpoints. + public const string ErrorEndpointsView = "error:endpoints:view"; + /// + public const string ErrorEndpointsManage = "error:endpoints:manage"; + /// + public const string ErrorEndpointsDelete = "error:endpoints:delete"; + + /// Heartbeats area — viewing heartbeat status for endpoints. + public const string ErrorHeartbeatsView = "error:heartbeats:view"; + + /// Custom checks area — viewing and deleting custom check results. + public const string ErrorCustomChecksView = "error:customchecks:view"; + /// + public const string ErrorCustomChecksDelete = "error:customchecks:delete"; + + /// Sagas area — viewing saga audit data. + public const string ErrorSagasView = "error:sagas:view"; + + /// Event log area — viewing the event log. + public const string ErrorEventLogView = "error:eventlog:view"; + + /// Licensing area — viewing and managing license configuration. + public const string ErrorLicensingView = "error:licensing:view"; + /// + public const string ErrorLicensingManage = "error:licensing:manage"; + + /// Notifications area — viewing, managing, and testing notification settings. + public const string ErrorNotificationsView = "error:notifications:view"; + /// + public const string ErrorNotificationsManage = "error:notifications:manage"; + /// + public const string ErrorNotificationsTest = "error:notifications:test"; + + /// Retry redirects area — viewing and managing message redirect rules. + public const string ErrorRedirectsView = "error:redirects:view"; + /// + public const string ErrorRedirectsManage = "error:redirects:manage"; + + /// Queue addresses area — viewing and deleting queue address entries. + public const string ErrorQueuesView = "error:queues:view"; + /// + public const string ErrorQueuesDelete = "error:queues:delete"; + + /// Throughput area — viewing and managing throughput reports and settings. + public const string ErrorThroughputView = "error:throughput:view"; + /// + public const string ErrorThroughputManage = "error:throughput:manage"; + + /// Platform connections area — viewing and managing broker/platform connection settings. + public const string ErrorConnectionsView = "error:connections:view"; + /// + public const string ErrorConnectionsManage = "error:connections:manage"; + + // ───────────────────────────── Audit instance ───────────────────────────── + + /// Audit instance (separate process) — read-only audit message log. + public const string AuditMessageView = "audit:message:view"; + /// Audit instance — viewing platform connection details. + public const string AuditConnectionView = "audit:connection:view"; + /// Audit instance — viewing known endpoints. + public const string AuditEndpointView = "audit:endpoint:view"; + /// Audit instance — viewing saga audit data. + public const string AuditSagaView = "audit:saga:view"; + + // ───────────────────────────── Monitoring instance ───────────────────────────── + + /// Monitoring instance (separate process) — viewing endpoint metrics. + public const string MonitoringEndpointView = "monitoring:endpoint:view"; + /// Monitoring instance — removing a monitored endpoint instance. + public const string MonitoringEndpointDelete = "monitoring:endpoint:delete"; + /// Monitoring instance — viewing platform connection details. + public const string MonitoringConnectionView = "monitoring:connection:view"; + /// Monitoring instance — viewing license status. + public const string MonitoringLicenseView = "monitoring:license:view"; + + /// + /// The complete set of known permissions, derived from all public const string + /// fields declared on this class. Used by the policy provider and coverage tests. + /// + public static readonly IReadOnlySet All = BuildAll(); + + static IReadOnlySet BuildAll() + { + var set = new HashSet(); + foreach (var field in typeof(Permissions).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.IsLiteral && !field.IsInitOnly && field.FieldType == typeof(string)) + { + var value = (string?)field.GetValue(null); + if (value != null) + { + set.Add(value); + } + } + } + return set; + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs new file mode 100644 index 0000000000..7375919263 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RolePermissions.cs @@ -0,0 +1,123 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; + +/// +/// Role → permission policy. Two roles: +/// +/// reader — granted every *:*:view permission (read-only access). +/// writer — granted every permission (*:*:*). +/// +/// The wildcard patterns (* is a colon-segment wildcard) are the source of truth, but they are +/// expanded once at type initialization against into a concrete, +/// immutable of granted permissions per role. As a result both +/// and are O(1) hash lookups with no +/// per-call pattern matching or allocation. +/// +public static class RolePermissions +{ + /// Read-only role: every *:*:view permission. + public const string Reader = "reader"; + + /// Full-access role: every permission. + public const string Writer = "writer"; + + // Source of truth: the wildcard pattern(s) each role grants. + static readonly Dictionary RolePatterns = new(StringComparer.OrdinalIgnoreCase) + { + [Reader] = ["*:*:view"], + [Writer] = ["*:*:*"], + }; + + // Expanded once against the full permission catalogue: role -> concrete granted permissions. + static readonly FrozenDictionary> PermissionsByRole = Expand(); + + /// + /// Returns if any of the supplied grants the + /// requested . O(1) per role — a frozen-set membership test. + /// + public static bool IsGranted(IEnumerable roles, string permission) + { + foreach (var role in roles) + { + if (PermissionsByRole.TryGetValue(role, out var granted) && granted.Contains(permission)) + { + return true; + } + } + + return false; + } + + /// + /// The complete set of permissions granted to a single role (empty if the role is unknown). + /// O(1) and allocation-free — returns the precomputed frozen set. + /// + public static IReadOnlySet GetPermissions(string role) => + PermissionsByRole.TryGetValue(role, out var granted) ? granted : FrozenSet.Empty; + + /// + /// The union of permissions granted across several . Allocation-free for the + /// common single-role case; only the multi-role union allocates. + /// + public static IReadOnlySet GetPermissions(IEnumerable roles) + { + var list = roles as IReadOnlyList ?? roles.ToList(); + if (list.Count <= 1) + { + return list.Count == 0 ? FrozenSet.Empty : GetPermissions(list[0]); + } + + var union = new HashSet(StringComparer.Ordinal); + foreach (var role in list) + { + if (PermissionsByRole.TryGetValue(role, out var granted)) + { + union.UnionWith(granted); + } + } + + return union; + } + + static FrozenDictionary> Expand() + { + var expanded = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var (role, patterns) in RolePatterns) + { + expanded[role] = Permissions.All + .Where(permission => patterns.Any(pattern => Matches(pattern, permission))) + .ToFrozenSet(StringComparer.Ordinal); + } + + return expanded.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + /// Matches a colon-delimited permission against a pattern where * is a segment wildcard. + static bool Matches(string pattern, string permission) + { + var patternSegments = pattern.Split(':'); + var permissionSegments = permission.Split(':'); + + if (patternSegments.Length != permissionSegments.Length) + { + return false; + } + + for (var i = 0; i < patternSegments.Length; i++) + { + if (patternSegments[i] != "*" + && !string.Equals(patternSegments[i], permissionSegments[i], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs b/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs new file mode 100644 index 0000000000..6a1c40c917 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/RolesClaimExtractor.cs @@ -0,0 +1,141 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth; + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; + +/// +/// Reads role values out of a at a configurable path. +/// Supports a flat claim name (roles) or a dotted path into a nested JSON object claim +/// (realm_access.roles). Used by RolesClaimsTransformation to normalize per-IdP token +/// shapes (Keycloak, Microsoft Entra ID, AWS Cognito, etc.) into a canonical set of role values. +/// +public static class RolesClaimExtractor +{ + /// + /// Extracts every role value reachable at on . + /// Returns an empty list when the path is absent or the value cannot be interpreted as a string or + /// array of strings — never throws on malformed input. + /// + public static IReadOnlyList Extract(ClaimsPrincipal principal, string rolesClaimPath) + { + if (principal is null || string.IsNullOrWhiteSpace(rolesClaimPath)) + { + return Array.Empty(); + } + + var segments = rolesClaimPath.Split('.'); + var topClaimType = segments[0]; + var results = new List(); + + foreach (var claim in principal.FindAll(topClaimType)) + { + if (segments.Length == 1) + { + AddFlatClaimValues(results, claim.Value); + } + else + { + AddNestedClaimValues(results, claim.Value, segments); + } + } + + return results; + } + + static void AddFlatClaimValues(List results, string claimValue) + { + // Flat claim values are typically a single role string per claim (the JWT bearer middleware + // explodes a top-level JSON array of strings into one claim per element). The fallback path + // handles the rare case where an IdP serialises the array into a single claim value. + if (LooksLikeJsonArray(claimValue)) + { + if (TryParse(claimValue, out var doc)) + { + using (doc) + { + AppendStringArray(results, doc.RootElement); + } + return; + } + } + + results.Add(claimValue); + } + + static void AddNestedClaimValues(List results, string claimValue, string[] segments) + { + if (!TryParse(claimValue, out var doc)) + { + return; + } + + using (doc) + { + var node = doc.RootElement; + for (var i = 1; i < segments.Length; i++) + { + if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(segments[i], out var next)) + { + return; + } + node = next; + } + + AppendStringOrArray(results, node); + } + } + + static void AppendStringOrArray(List results, JsonElement node) + { + if (node.ValueKind == JsonValueKind.String) + { + var single = node.GetString(); + if (!string.IsNullOrEmpty(single)) + { + results.Add(single); + } + } + else if (node.ValueKind == JsonValueKind.Array) + { + AppendStringArray(results, node); + } + } + + static void AppendStringArray(List results, JsonElement array) + { + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + results.Add(value); + } + } + } + } + + static bool LooksLikeJsonArray(string value) + { + var trimmed = value.AsSpan().TrimStart(); + return trimmed.Length > 0 && trimmed[0] == '['; + } + + static bool TryParse(string value, out JsonDocument document) + { + try + { + document = JsonDocument.Parse(value); + return true; + } + catch (JsonException) + { + document = null!; + return false; + } + } +} diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index efbba73239..9c2d74158f 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -32,6 +32,9 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ValidateIssuerSigningKey = SettingsReader.Read(rootNamespace, "Authentication.ValidateIssuerSigningKey", true); RequireHttpsMetadata = SettingsReader.Read(rootNamespace, "Authentication.RequireHttpsMetadata", true); + RolesClaim = SettingsReader.Read(rootNamespace, "Authentication.RolesClaim", "roles"); + RoleBasedAuthorizationEnabled = SettingsReader.Read(rootNamespace, "Authentication.RoleBasedAuthorizationEnabled", false); + // ServicePulse settings are only relevant for the primary ServiceControl instance // which serves the OIDC configuration endpoint that ServicePulse uses for login if (requireServicePulseSettings) @@ -114,6 +117,21 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public string ServicePulseApiScopes { get; } + /// + /// Path within the JWT where the user's role values live. Defaults to realm_access.roles + /// to match Keycloak's out-of-box token shape. A flat claim name like roles is used when + /// the identity provider emits role values as top-level claims (Keycloak with a "User Realm Role" + /// mapper, Microsoft Entra ID app roles, AWS Cognito groups, etc.). The dotted form navigates + /// into a nested JSON object claim. + /// + public string RolesClaim { get; } + + /// + /// Is RBAC enabled. When false, all authenticated users have access to all methods. When true, + /// role based authorization rules are applied. + /// + public bool RoleBasedAuthorizationEnabled { get; } + void Validate(bool requireServicePulseSettings) { if (Enabled) @@ -187,8 +205,8 @@ void LogConfiguration(bool requireServicePulseSettings) var servicePulseAuthorityDisplay = requireServicePulseSettings ? (ServicePulseAuthority ?? "(not configured)") : "(n/a)"; var servicePulseApiScopesDisplay = requireServicePulseSettings ? (ServicePulseApiScopes ?? "(not configured)") : "(n/a)"; - logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", - Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); + logger.LogInformation("Authentication settings: Enabled={Enabled}, Authority={Authority}, Audience={Audience}, ValidateIssuer={ValidateIssuer}, ValidateAudience={ValidateAudience}, ValidateLifetime={ValidateLifetime}, ValidateIssuerSigningKey={ValidateIssuerSigningKey}, RequireHttpsMetadata={RequireHttpsMetadata}, RolesClaim={RolesClaim}, ServicePulseClientId={ServicePulseClientId}, ServicePulseAuthority={ServicePulseAuthority}, ServicePulseApiScopes={ServicePulseApiScopes}", + Enabled, authorityDisplay, audienceDisplay, ValidateIssuer, ValidateAudience, ValidateLifetime, ValidateIssuerSigningKey, RequireHttpsMetadata, RolesClaim, servicePulseClientIdDisplay, servicePulseAuthorityDisplay, servicePulseApiScopesDisplay); // Warn about potential misconfigurations var hasAuthConfig = !string.IsNullOrWhiteSpace(Authority) || !string.IsNullOrWhiteSpace(Audience); diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs index 4781e5e0fc..0f94dba925 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Monitoring.AcceptanceTests.Security.OpenIdConnect { using System.Net.Http; + using System.Security.Claims; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.OpenIdConnect; @@ -32,6 +33,7 @@ public void ConfigureAuth() configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Monitoring) .WithConfigurationValidationDisabled() .WithAuthenticationEnabled() + .WithRoleBasedAuthorizationEnabled() .WithAuthority(mockOidcServer.Authority) .WithAudience(TestAudience) .WithRequireHttpsMetadata(false); @@ -92,7 +94,11 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // The "reader" role grants every *:*:view permission, including + // monitoring:endpoint:view required by /monitored-endpoints. Without a + // role-bearing claim the request would be 403. + var validToken = mockOidcServer.GenerateToken( + additionalClaims: new[] { new Claim("roles", "reader") }); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 1d233e7cc7..aaab866c82 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -100,6 +100,7 @@ async Task InitializeServiceControl(ScenarioContext context) hostBuilder.Logging.ConfigureLogging(LogLevel.Information); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlMonitoring((criticalErrorContext, cancellationToken) => { var logitem = new ScenarioContext.LogItem diff --git a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt index c4cfe5ad09..fed5a2acf4 100644 --- a/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.Monitoring.UnitTests/ApprovalFiles/SettingsTests.PlatformSampleSettings.approved.txt @@ -14,7 +14,9 @@ "RequireHttpsMetadata": true, "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, diff --git a/src/ServiceControl.Monitoring/Connection/ConnectionController.cs b/src/ServiceControl.Monitoring/Connection/ConnectionController.cs index e765007b73..28978672a6 100644 --- a/src/ServiceControl.Monitoring/Connection/ConnectionController.cs +++ b/src/ServiceControl.Monitoring/Connection/ConnectionController.cs @@ -2,8 +2,10 @@ { using System; using System.Text.Json; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth; [ApiController] public class ConnectionController(ReceiveAddresses receiveAddresses) : ControllerBase @@ -11,6 +13,7 @@ public class ConnectionController(ReceiveAddresses receiveAddresses) : Controlle readonly string mainInputQueue = receiveAddresses.MainReceiveAddress; readonly TimeSpan defaultInterval = TimeSpan.FromSeconds(1); + [Authorize(Policy = Permissions.MonitoringConnectionView)] [Route("connection")] [HttpGet] public IActionResult GetConnectionDetails() => diff --git a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs index ca648ac222..6e197e463a 100644 --- a/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs @@ -16,6 +16,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlMonitoring((_, __) => Task.CompletedTask, settings, endpointConfiguration); hostBuilder.AddServiceControlMonitoringApi(); diff --git a/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs b/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs index 134bf92716..04bd58ac86 100644 --- a/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs +++ b/src/ServiceControl.Monitoring/Http/Diagrams/DiagramApiController.cs @@ -1,21 +1,26 @@ namespace ServiceControl.Monitoring.Http.Diagrams { using Infrastructure.Api; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; [ApiController] public class DiagramApiController(IEndpointMetricsApi endpointMetricsApi) : ControllerBase { + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints")] [HttpGet] public MonitoredEndpoint[] GetAllEndpointsMetrics([FromQuery] int? history = null) => endpointMetricsApi.GetAllEndpointsMetrics(history); + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints/{endpointName}")] [HttpGet] public ActionResult GetSingleEndpointMetrics(string endpointName, [FromQuery] int? history = null) => endpointMetricsApi.GetSingleEndpointMetrics(endpointName, history); + [Authorize(Policy = Permissions.MonitoringEndpointDelete)] [Route("monitored-instance/{endpointName}/{instanceId}")] [HttpDelete] public IActionResult DeleteEndpointInstance(string endpointName, string instanceId) @@ -25,6 +30,7 @@ public IActionResult DeleteEndpointInstance(string endpointName, string instance return Ok(); } + [Authorize(Policy = Permissions.MonitoringEndpointView)] [Route("monitored-endpoints/disconnected")] [HttpGet] public ActionResult DisconnectedEndpointCount() => endpointMetricsApi.DisconnectedEndpointCount(); diff --git a/src/ServiceControl.Monitoring/Http/LicenseController.cs b/src/ServiceControl.Monitoring/Http/LicenseController.cs index d76e2e9361..55bcdfbbb5 100644 --- a/src/ServiceControl.Monitoring/Http/LicenseController.cs +++ b/src/ServiceControl.Monitoring/Http/LicenseController.cs @@ -1,11 +1,14 @@ namespace ServiceControl.Monitoring.Http { + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth; using ServiceControl.Monitoring.Licensing; [ApiController] public class LicenseController(ActiveLicense activeLicense) : ControllerBase { + [Authorize(Policy = Permissions.MonitoringLicenseView)] [Route("license")] [HttpGet] public ActionResult License(bool refresh) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index 6873e229b3..72e33e6946 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -14,7 +14,9 @@ "RequireHttpsMetadata": true, "ServicePulseAuthority": null, "ServicePulseClientId": null, - "ServicePulseApiScopes": null + "ServicePulseApiScopes": null, + "RolesClaim": "roles", + "RoleBasedAuthorizationEnabled": false }, "ForwardedHeadersSettings": { "Enabled": true, diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs index 1ee79cfbbc..eb72656fb1 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs @@ -2,7 +2,9 @@ namespace ServiceControl.CompositeViews.Messages; using System.Collections.Generic; using System.Threading.Tasks; +using Infrastructure.Auth; using Infrastructure.WebApi; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -16,6 +18,7 @@ public class GetMessages2Controller( SearchEndpointApi searchEndpointApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages2")] [HttpGet] public async Task> Messages( diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs b/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs index 7bd650b453..ab18a6da9e 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -12,6 +14,7 @@ public class GetMessagesByConversationController(MessagesByConversationApi byConversationApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("conversations/{conversationId:required:minlength(1)}")] [HttpGet] public async Task> Messages([FromQuery] PagingInfo pagingInfo, diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs index e7133ba20a..d586fe34cd 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs @@ -5,8 +5,10 @@ namespace ServiceControl.CompositeViews.Messages using System.Net.Http; using System.Threading.Tasks; using Api.Contracts; + using Infrastructure.Auth; using Infrastructure.WebApi; using MessageCounting; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -33,6 +35,7 @@ public class GetMessagesController( ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages")] [HttpGet] public async Task> Messages([FromQuery] PagingInfo pagingInfo, @@ -48,6 +51,7 @@ public async Task> Messages([FromQuery] PagingInfo pagingInf return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages")] [HttpGet] public async Task> MessagesForEndpoint([FromQuery] PagingInfo pagingInfo, @@ -64,6 +68,7 @@ public async Task> MessagesForEndpoint([FromQuery] PagingInf } // the endpoint name is needed in the route to match the route and forward it as path and query to the remotes + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/audit-count")] [HttpGet] public async Task> GetEndpointAuditCounts([FromQuery] PagingInfo pagingInfo, string endpoint) @@ -75,6 +80,7 @@ public async Task> GetEndpointAuditCounts([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/{id}/body")] [HttpGet] public async Task Get(string id, [FromQuery(Name = "instance_id")] string instanceId) @@ -114,6 +120,7 @@ public async Task Get(string id, [FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -126,6 +133,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, @@ -139,6 +147,7 @@ public async Task> SearchByKeyWord([FromQuery] PagingInfo pa return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -151,6 +160,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpoint}/messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyword([FromQuery] PagingInfo pagingInfo, diff --git a/src/ServiceControl/Connection/ConnectionController.cs b/src/ServiceControl/Connection/ConnectionController.cs index a85522a84c..b87d4d8dc7 100644 --- a/src/ServiceControl/Connection/ConnectionController.cs +++ b/src/ServiceControl/Connection/ConnectionController.cs @@ -4,6 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] @@ -13,6 +15,7 @@ public class ConnectionController(IPlatformConnectionBuilder builder) : Controll // This controller doesn't use the default serialization settings because // ServicePulse and the Platform Connector Plugin expect the connection // details the be serialized and formatted in a specific way + [Authorize(Policy = Permissions.ErrorConnectionsView)] [Route("connection")] [HttpGet] public async Task GetConnectionDetails() diff --git a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs index 0cc06bbd0c..f9fb690757 100644 --- a/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs +++ b/src/ServiceControl/CustomChecks/Web/CustomCheckController.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Contracts.CustomChecks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -15,6 +17,7 @@ public class CustomCheckController(ICustomChecksDataStore checksDataStore, IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorCustomChecksView)] [Route("customchecks")] [HttpGet] public async Task> CustomChecks([FromQuery] PagingInfo pagingInfo, string status = null) @@ -27,6 +30,7 @@ public async Task> CustomChecks([FromQuery] PagingInfo paging return stats.Results; } + [Authorize(Policy = Permissions.ErrorCustomChecksDelete)] [Route("customchecks/{id}")] [HttpDelete] public async Task Delete(Guid id) diff --git a/src/ServiceControl/EventLog/EventLogApiController.cs b/src/ServiceControl/EventLog/EventLogApiController.cs index 1872154be7..605efc453d 100644 --- a/src/ServiceControl/EventLog/EventLogApiController.cs +++ b/src/ServiceControl/EventLog/EventLogApiController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class EventLogApiController(IEventLogDataStore logDataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorEventLogView)] [Route("eventlogitems")] [HttpGet] public async Task> Items([FromQuery] PagingInfo pagingInfo) diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index ebc08958cf..c25485bf5c 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -25,6 +25,7 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl/Licensing/LicenseController.cs b/src/ServiceControl/Licensing/LicenseController.cs index 15539d9db0..9f9e56cb6e 100644 --- a/src/ServiceControl/Licensing/LicenseController.cs +++ b/src/ServiceControl/Licensing/LicenseController.cs @@ -2,6 +2,8 @@ { using System.Threading; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Monitoring.HeartbeatMonitoring; using Particular.ServiceControl.Licensing; @@ -11,6 +13,7 @@ [Route("api")] public class LicenseController(ActiveLicense activeLicense, Settings settings, MassTransitConnectorHeartbeatStatus connectorHeartbeatStatus) : ControllerBase { + [Authorize(Policy = Permissions.ErrorLicensingView)] [HttpGet] [Route("license")] public async Task> License(bool refresh, string clientName, CancellationToken cancellationToken) diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index bad1ec4cf5..84384bec6f 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -2,8 +2,10 @@ namespace ServiceControl.MessageFailures.Api { using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -13,6 +15,7 @@ namespace ServiceControl.MessageFailures.Api [Route("api")] public class ArchiveMessagesController(IMessageSession messageSession, IErrorMessageDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/archive")] [HttpPost] [HttpPatch] @@ -34,6 +37,7 @@ public async Task ArchiveBatch(string[] messageIds) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/groups/{classifier?}")] [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace") @@ -45,6 +49,7 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex return Ok(results); } + [Authorize(Policy = Permissions.ErrorMessagesArchive)] [Route("errors/{messageId:required:minlength(1)}/archive")] [HttpPost] [HttpPatch] @@ -55,6 +60,7 @@ public async Task Archive(string messageId) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("archive/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task> GetGroup(string groupId, string status = default, string modified = default) diff --git a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs index 3ce42fc3ff..d5f5d587f6 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Text; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NServiceBus; @@ -21,10 +23,12 @@ public class EditFailedMessagesController( ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesEdit)] [Route("edit/config")] [HttpGet] public EditConfigurationModel Config() => GetEditConfiguration(); + [Authorize(Policy = Permissions.ErrorMessagesEdit)] [Route("edit/{failedMessageId:required:minlength(1)}")] [HttpPost] public async Task> Edit(string failedMessageId, [FromBody] EditMessageModel edit) diff --git a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs index 60f9f08ca9..06c7260d26 100644 --- a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class GetAllErrorsController(IErrorMessageDataStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors")] [HttpGet] public async Task> ErrorsGet([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string queueAddress) @@ -28,6 +31,7 @@ public async Task> ErrorsGet([FromQuery] PagingInfo pag return results.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors")] [HttpHead] public async Task ErrorsHead(string status, string modified, string queueAddress) @@ -41,6 +45,7 @@ public async Task ErrorsHead(string status, string modified, string queueAddress Response.WithQueryStatsInfo(queryResult); } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("endpoints/{endpointname}/errors")] [HttpGet] public async Task> ErrorsByEndpointName([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string endpointName) @@ -58,6 +63,7 @@ public async Task> ErrorsByEndpointName([FromQuery] Pag return results.Results; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/summary")] [HttpGet] public async Task> ErrorsSummary() => await store.ErrorsSummary(); diff --git a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs index 437b6fc5a3..bb419a014c 100644 --- a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.MessageFailures.Api { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; @@ -8,6 +10,7 @@ [Route("api")] public class GetErrorByIdController(IErrorMessageDataStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorBy(string failedMessageId) @@ -17,6 +20,7 @@ public async Task> ErrorBy(string failedMessageId) return result == null ? NotFound() : result; } + [Authorize(Policy = Permissions.ErrorMessagesView)] [Route("errors/last/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorLastBy(string failedMessageId) diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 611ee01e5c..1ad75d3bee 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -5,7 +5,9 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -13,6 +15,7 @@ [Route("api")] public class PendingRetryMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/retry")] [HttpPost] public async Task RetryBy(string[] ids) @@ -28,6 +31,7 @@ public async Task RetryBy(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/queues/retry")] [HttpPost] public async Task RetryBy(PendingRetryRequest request) diff --git a/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs b/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs index 9e01c66e15..44e043426c 100644 --- a/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs +++ b/src/ServiceControl/MessageFailures/Api/QueueAddressController.cs @@ -2,7 +2,9 @@ { using System.Collections.Generic; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -11,6 +13,7 @@ [Route("api")] public class QueueAddressController(IQueueAddressStore store) : ControllerBase { + [Authorize(Policy = Permissions.ErrorQueuesView)] [Route("errors/queues/addresses")] [HttpGet] public async Task> GetAddresses([FromQuery] PagingInfo pagingInfo) @@ -22,6 +25,7 @@ public async Task> GetAddresses([FromQuery] PagingInfo pagin return result.Results; } + [Authorize(Policy = Permissions.ErrorQueuesView)] [Route("errors/queues/addresses/search/{search}")] [HttpGet] public async Task>> GetAddressesBySearchTerm([FromQuery] PagingInfo pagingInfo, string search = null) diff --git a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs index dc4eddf431..21c05d2cc1 100644 --- a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs @@ -8,7 +8,9 @@ namespace ServiceControl.MessageFailures.Api using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using NServiceBus; @@ -17,6 +19,7 @@ namespace ServiceControl.MessageFailures.Api [Route("api")] public class ResolveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/resolve")] [HttpPatch] public async Task ResolveBy(UniqueMessageIdsModel request) @@ -61,6 +64,7 @@ await session.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("pendingretries/queues/resolve")] [HttpPatch] public async Task ResolveBy(QueueModel queueModel) diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index c486129df9..29fb12966c 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -23,6 +25,7 @@ public class RetryMessagesController( IMessageSession messageSession, ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{failedMessageId:required:minlength(1)}/retry")] [HttpPost] public async Task RetryMessageBy([FromQuery(Name = "instance_id")] string instanceId, string failedMessageId) @@ -49,6 +52,7 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/retry")] [HttpPost] public async Task RetryAllBy(List messageIds) @@ -63,6 +67,7 @@ public async Task RetryAllBy(List messageIds) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/queues/{queueAddress:required:minlength(1)}/retry")] [HttpPost] public async Task RetryAllBy(string queueAddress) @@ -76,6 +81,7 @@ await messageSession.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/retry/all")] [HttpPost] public async Task RetryAll() @@ -85,6 +91,7 @@ public async Task RetryAll() return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesRetry)] [Route("errors/{endpointName:required:minlength(1)}/retry/all")] [HttpPost] public async Task RetryAllByEndpoint(string endpointName) diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index 0a0271d9fa..d36940bc99 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -4,7 +4,9 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; @@ -12,6 +14,7 @@ [Route("api")] public class UnArchiveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/unarchive")] [HttpPatch] public async Task Unarchive(string[] ids) @@ -28,6 +31,7 @@ public async Task Unarchive(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.ErrorMessagesUnarchive)] [Route("errors/{from}...{to}/unarchive")] [HttpPatch] public async Task Unarchive(string from, string to) diff --git a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs index 4899498683..2eed6206cc 100644 --- a/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs +++ b/src/ServiceControl/MessageRedirects/Api/MessageRedirectsController.cs @@ -7,9 +7,11 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Contracts.MessageRedirects; + using Infrastructure.Auth; using Infrastructure.DomainEvents; using Infrastructure.WebApi; using MessageFailures.InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Infrastructure; @@ -25,6 +27,7 @@ public class MessageRedirectsController( IDomainEvents events) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects")] [HttpPost] public async Task NewRedirects(MessageRedirectRequest request) @@ -93,6 +96,7 @@ await session.SendLocal(new RetryPendingMessages return StatusCode((int)HttpStatusCode.Created, messageRedirect); } + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects/{messageRedirectId:guid}")] [HttpPut] public async Task UpdateRedirect(Guid messageRedirectId, MessageRedirectRequest request) @@ -135,6 +139,7 @@ public async Task UpdateRedirect(Guid messageRedirectId, MessageR return NoContent(); } + [Authorize(Policy = Permissions.ErrorRedirectsManage)] [Route("redirects/{messageRedirectId:guid}")] [HttpDelete] public async Task DeleteRedirect(Guid messageRedirectId) @@ -162,6 +167,7 @@ await events.Raise(new MessageRedirectRemoved return NoContent(); } + [Authorize(Policy = Permissions.ErrorRedirectsView)] [Route("redirect")] [HttpHead] public async Task CountRedirects() @@ -172,6 +178,7 @@ public async Task CountRedirects() Response.WithTotalCount(redirects.Redirects.Count); } + [Authorize(Policy = Permissions.ErrorRedirectsView)] [Route("redirects")] [HttpGet] public async Task> Redirects(string sort, string direction, [FromQuery] PagingInfo pagingInfo) diff --git a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs index 5b64d5a22d..0926075b59 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsMonitoringController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using CompositeViews.Messages; + using Infrastructure.Auth; using Infrastructure.WebApi; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; @@ -25,10 +26,12 @@ public class EndpointsMonitoringController( IMonitoringDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorHeartbeatsView)] [Route("heartbeats/stats")] [HttpGet] public EndpointMonitoringStats HeartbeatStats() => monitoring.GetStats(); + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpoints")] [HttpGet] public EndpointsView[] Endpoints() => monitoring.GetEndpoints(); @@ -44,6 +47,7 @@ public void GetSupportedOperations() Response.Headers.AccessControlExposeHeaders = "Allow"; } + [Authorize(Policy = Permissions.ErrorEndpointsDelete)] [Route("endpoints/{endpointId}")] [HttpDelete] public async Task DeleteEndpoint(Guid endpointId) @@ -59,6 +63,7 @@ public async Task DeleteEndpoint(Guid endpointId) return NoContent(); } + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpoints/known")] [HttpGet] public async Task> KnownEndpoints([FromQuery] PagingInfo pagingInfo) @@ -70,6 +75,7 @@ public async Task> KnownEndpoints([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.ErrorEndpointsManage)] [Route("endpoints/{endpointId}")] [HttpPatch] public async Task Monitoring(Guid endpointId, [FromBody] EndpointUpdateModel data) diff --git a/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs b/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs index be1807e54c..a1d55138db 100644 --- a/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs +++ b/src/ServiceControl/Monitoring/Web/EndpointsSettingsController.cs @@ -4,6 +4,8 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Infrastructure.Auth; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; using ServiceBus.Management.Infrastructure.Settings; @@ -25,6 +27,7 @@ public class EndpointsSettingsController( IEndpointSettingsStore dataStore, Settings settings) : ControllerBase { + [Authorize(Policy = Permissions.ErrorEndpointsView)] [Route("endpointssettings")] [HttpGet] public async IAsyncEnumerable Endpoints([EnumeratorCancellation] CancellationToken token) @@ -49,6 +52,7 @@ public async IAsyncEnumerable Endpoints([EnumeratorCancellation] C } } + [Authorize(Policy = Permissions.ErrorEndpointsManage)] [Route("endpointssettings/{endpointName?}")] [HttpPatch] public async Task diff --git a/src/ServiceControl/Notifications/Api/NotificationsController.cs b/src/ServiceControl/Notifications/Api/NotificationsController.cs index af5f90cfe0..e42cfa5473 100644 --- a/src/ServiceControl/Notifications/Api/NotificationsController.cs +++ b/src/ServiceControl/Notifications/Api/NotificationsController.cs @@ -4,6 +4,8 @@ using System.Net; using System.Threading.Tasks; using Email; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; using ServiceBus.Management.Infrastructure.Settings; @@ -12,6 +14,7 @@ [Route("api")] public class NotificationsController(IErrorMessageDataStore store, Settings settings, EmailSender emailSender) : ControllerBase { + [Authorize(Policy = Permissions.ErrorNotificationsView)] [Route("notifications/email")] [HttpGet] public async Task GetEmailNotificationsSettings() @@ -22,6 +25,7 @@ public async Task GetEmailNotificationsSettings() return notificationsSettings.Email; } + [Authorize(Policy = Permissions.ErrorNotificationsManage)] [Route("notifications/email/toggle")] [HttpPost] public async Task ToggleEmailNotifications(ToggleEmailNotifications request) @@ -36,6 +40,7 @@ public async Task ToggleEmailNotifications(ToggleEmailNotificatio return Ok(); } + [Authorize(Policy = Permissions.ErrorNotificationsManage)] [Route("notifications/email")] [HttpPost] public async Task UpdateSettings(UpdateEmailNotificationsSettingsRequest request) @@ -60,6 +65,7 @@ public async Task UpdateSettings(UpdateEmailNotificationsSettings return Ok(); } + [Authorize(Policy = Permissions.ErrorNotificationsTest)] [Route("notifications/email/test")] [HttpPost] public async Task SendTestEmail() diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index f40611db85..69c805f199 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class FailureGroupsArchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsArchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/archive")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index b03b2b702d..7a3964f9cb 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; using MessageFailures.Api; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; using ServiceControl.Persistence; @@ -18,6 +20,7 @@ public class FailureGroupsController( IRetryHistoryDataStore retryStore) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/classifiers")] [HttpGet] public string[] GetSupportedClassifiers() @@ -32,6 +35,7 @@ public string[] GetSupportedClassifiers() return result; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpPost] public async Task EditComment(string groupId, string comment) @@ -41,6 +45,7 @@ public async Task EditComment(string groupId, string comment) return Accepted(); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpDelete] public async Task DeleteComment(string groupId) @@ -50,6 +55,7 @@ public async Task DeleteComment(string groupId) return Accepted(); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{classifier?}")] [HttpGet] public async Task GetAllGroups(string classifier = "Exception Type and Stack Trace", string classifierFilter = default) @@ -64,6 +70,7 @@ public async Task GetAllGroups(string classifier = "Exception return results; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors")] [HttpGet] public async Task> GetGroupErrors(string groupId, [FromQuery] SortInfo sortInfo, [FromQuery] PagingInfo pagingInfo, string status = default, string modified = default) @@ -75,6 +82,7 @@ public async Task> GetGroupErrors(string groupId, [From } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors")] [HttpHead] public async Task GetGroupErrorsCount(string groupId, string status = default, string modified = default) @@ -84,6 +92,7 @@ public async Task GetGroupErrorsCount(string groupId, string status = default, s Response.WithQueryStatsInfo(results); } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/history")] [HttpGet] public async Task GetRetryHistory() @@ -95,6 +104,7 @@ public async Task GetRetryHistory() return retryHistory; } + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task GetGroup(string groupId, string status = default, string modified = default) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 308d427217..8ef2ec86d2 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -2,6 +2,8 @@ namespace ServiceControl.Recoverability.API { using System; using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence; @@ -10,6 +12,7 @@ namespace ServiceControl.Recoverability.API [Route("api")] public class FailureGroupsRetryController(IMessageSession bus, RetryingManager retryingManager) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsRetry)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/retry")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index 5661c8dd78..37e2f0b6d9 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class FailureGroupsUnarchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsUnarchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/unarchive")] [HttpPost] public async Task UnarchiveGroupErrors(string groupId) diff --git a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs index 2e85be691f..9be6bec2b2 100644 --- a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs @@ -1,6 +1,8 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Infrastructure.Auth; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class UnacknowledgedGroupsController(IRetryHistoryDataStore retryStore, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.ErrorRecoverabilityGroupsView)] [Route("recoverability/unacknowledgedgroups/{groupId:required:minlength(1)}")] [HttpDelete] public async Task AcknowledgeOperation(string groupId) diff --git a/src/ServiceControl/SagaAudit/SagasController.cs b/src/ServiceControl/SagaAudit/SagasController.cs index 9b581b9758..d3c082de8f 100644 --- a/src/ServiceControl/SagaAudit/SagasController.cs +++ b/src/ServiceControl/SagaAudit/SagasController.cs @@ -2,7 +2,9 @@ namespace ServiceControl.SagaAudit { using System; using System.Threading.Tasks; + using Infrastructure.Auth; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; @@ -11,6 +13,7 @@ namespace ServiceControl.SagaAudit [Route("api")] public class SagasController(GetSagaByIdApi getSagaByIdApi) : ControllerBase { + [Authorize(Policy = Permissions.ErrorSagasView)] [Route("sagas/{id}")] [HttpGet] public async Task Sagas([FromQuery] PagingInfo pagingInfo, Guid id)