diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 22df7c0b8f..d82dffcd6d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -83,6 +83,7 @@ + diff --git a/src/ServiceControl.AcceptanceTesting/Auth/RecordingLoggerProvider.cs b/src/ServiceControl.AcceptanceTesting/Auth/RecordingLoggerProvider.cs new file mode 100644 index 0000000000..d776b0c80a --- /dev/null +++ b/src/ServiceControl.AcceptanceTesting/Auth/RecordingLoggerProvider.cs @@ -0,0 +1,60 @@ +#nullable enable +namespace ServiceControl.AcceptanceTesting.Auth; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +/// +/// An in-memory that captures log entries for test assertions. +/// Thread-safe. Use to read all captured entries; use +/// to filter by category. +/// +public sealed class RecordingLoggerProvider : ILoggerProvider +{ + readonly ConcurrentQueue entries = new(); + + public IReadOnlyList Entries => entries.ToArray(); + + public IReadOnlyList EntriesFor(string category) => + entries.Where(e => e.Category == category).ToArray(); + + public ILogger CreateLogger(string categoryName) => + new RecordingLogger(categoryName, entries); + + public void Dispose() { /* nothing to release */ } +} + +/// A captured log entry. +public sealed record LogEntry( + string Category, + LogLevel Level, + EventId EventId, + string Message, + Exception? Exception); + +sealed class RecordingLogger(string category, ConcurrentQueue sink) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + sink.Enqueue(new LogEntry(category, logLevel, eventId, message, exception)); + } + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/src/ServiceControl.AcceptanceTesting/IAcceptanceTestInfrastructureProvider.cs b/src/ServiceControl.AcceptanceTesting/IAcceptanceTestInfrastructureProvider.cs index dc56152687..96ce684c08 100644 --- a/src/ServiceControl.AcceptanceTesting/IAcceptanceTestInfrastructureProvider.cs +++ b/src/ServiceControl.AcceptanceTesting/IAcceptanceTestInfrastructureProvider.cs @@ -1,5 +1,6 @@ namespace ServiceControl.AcceptanceTesting { + using System; using System.Net.Http; using System.Text.Json; @@ -7,5 +8,12 @@ public interface IAcceptanceTestInfrastructureProvider { HttpClient HttpClient { get; } JsonSerializerOptions SerializerOptions { get; } + + /// + /// The DI container of the running ServiceControl host. + /// Exposed so tests can resolve internal services (e.g. IEnumerable<EndpointDataSource> + /// for the endpoint-completeness test) without coupling to a specific host type. + /// + IServiceProvider Services { get; } } } \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs index 03f5c156eb..ba81c603e8 100644 --- a/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs +++ b/src/ServiceControl.AcceptanceTesting/OpenIdConnect/MockOidcServer.cs @@ -206,6 +206,30 @@ public string GenerateToken( return new JwtSecurityTokenHandler().WriteToken(token); } + /// + /// Generates a valid JWT token carrying Keycloak-style realm_access roles. + /// The will expand these into + /// individual role claims so the RBAC evaluator can match them. + /// + /// The subject (sub) claim. + /// The roles to embed in realm_access.roles. + /// Token lifetime (default 1 hour). + public string GenerateTokenWithRealmRoles( + string subject = "test-user", + IEnumerable realmRoles = null, + TimeSpan? expiresIn = null) + { + var roles = realmRoles == null ? Array.Empty() : new List(realmRoles).ToArray(); + var realmAccessJson = JsonSerializer.Serialize(new { roles }); + + var additionalClaims = new List + { + new("realm_access", realmAccessJson, Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json) + }; + + return GenerateToken(subject, expiresIn, additionalClaims); + } + /// /// Generates an expired JWT token for testing token expiration. /// diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/Catalogue_completeness_tests.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/Catalogue_completeness_tests.cs new file mode 100644 index 0000000000..100b275ebc --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/Catalogue_completeness_tests.cs @@ -0,0 +1,151 @@ +#nullable enable +namespace ServiceControl.AcceptanceTests.Security.Authorization; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using NUnit.Framework; +using ServiceControl.Connection; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Fast unit-style tests (no host startup) that keep the permission catalogue honest. +/// +/// Two invariants are asserted: +/// +/// +/// Every constant in (except *) is either enforced +/// via an [Authorize(Policy = X)] attribute on a controller action (where X is +/// a member of ), or explicitly declared as unenforced in +/// . +/// Failing this means a new constant was added without enforcement or a known-unenforced entry. +/// +/// +/// Every entry in is genuinely unenforced. +/// Failing this means enforcement was added but the known-unenforced entry was not removed. +/// +/// +/// +/// +/// Assembly walk strategy: +/// +/// +/// All controllers live in the main ServiceControl assembly, anchored via +/// (a stable, non-auth-specific controller type). +/// +/// +/// and live in the +/// Infrastructure assembly, anchored via . +/// +/// +/// All assemblies scanned are already referenced by the test project; no runtime loading is needed. +/// +/// +[TestFixture] +public class Catalogue_completeness_tests +{ + /// + /// Collects all permission strings that appear as the Policy of an + /// [Authorize] attribute on any controller class or action method across the + /// ServiceControl assembly, filtered to only those that are members of . + /// + static IReadOnlySet CollectEnforcedPermissions() + { + // The main ServiceControl assembly hosts all controllers. + // Anchored via ConnectionController — a stable, always-present controller type. + var scAssembly = typeof(ConnectionController).Assembly; + + var enforced = new HashSet(StringComparer.Ordinal); + + foreach (var type in scAssembly.GetExportedTypes()) + { + // Check class-level [Authorize(Policy = X)] + foreach (var attr in type.GetCustomAttributes(inherit: true)) + { + if (attr.Policy != null && Permissions.All.Contains(attr.Policy)) + { + enforced.Add(attr.Policy); + } + } + + // Check method-level [Authorize(Policy = X)] on all public instance methods + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + foreach (var attr in method.GetCustomAttributes(inherit: true)) + { + if (attr.Policy != null && Permissions.All.Contains(attr.Policy)) + { + enforced.Add(attr.Policy); + } + } + } + } + + return enforced; + } + + [Test] + public void Every_catalogue_constant_is_either_enforced_or_declared_as_known_unenforced() + { + var enforced = CollectEnforcedPermissions(); + + // All declared permissions except the wildcard grant ("*" is not a real permission) + var catalogue = Permissions.All + .Where(p => p != "*") + .ToHashSet(StringComparer.Ordinal); + + // Unenforced = declared but not yet wired to any controller action + var unenforced = catalogue + .Except(enforced) + .ToHashSet(StringComparer.Ordinal); + + // Any permission that is unenforced AND not listed in KnownUnenforcedPermissions is a gap + var gaps = unenforced + .Except(KnownUnenforcedPermissions.Set) + .OrderBy(p => p) + .ToList(); + + if (gaps.Count > 0) + { + Assert.Fail( + $"The following permission(s) are declared in Permissions but are neither enforced " + + $"by an [Authorize(Policy = X)] attribute on a controller action nor listed in KnownUnenforcedPermissions.Set.\n" + + $"Either add [Authorize(Policy = \"{gaps[0]}\")] to the appropriate controller action, " + + $"or add the constant to KnownUnenforcedPermissions.Set until enforcement is implemented:\n" + + string.Join("\n", gaps.Select(p => $" - {p}"))); + } + } + + [Test] + public void KnownUnenforced_set_contains_no_stale_entries() + { + var enforced = CollectEnforcedPermissions(); + + // All declared permissions except the wildcard grant ("*" is not a real permission) + var catalogue = Permissions.All + .Where(p => p != "*") + .ToHashSet(StringComparer.Ordinal); + + // Unenforced = declared but not yet wired to any controller action + var unenforced = catalogue + .Except(enforced) + .ToHashSet(StringComparer.Ordinal); + + // KnownUnenforced entries that are now enforced (stale) + var stale = KnownUnenforcedPermissions.Set + .Except(unenforced) + .OrderBy(p => p) + .ToList(); + + if (stale.Count > 0) + { + Assert.Fail( + $"The following permission(s) are listed in KnownUnenforcedPermissions.Set but are " + + $"now enforced by an [Authorize(Policy = X)] attribute — remove them from " + + $"KnownUnenforcedPermissions.Set to keep the set accurate:\n" + + string.Join("\n", stale.Select(p => $" - {p}"))); + } + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/Every_endpoint_declares_an_authorization_decision.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/Every_endpoint_declares_an_authorization_decision.cs new file mode 100644 index 0000000000..000a0e2a11 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/Every_endpoint_declares_an_authorization_decision.cs @@ -0,0 +1,274 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using System.Threading.Tasks; + using AcceptanceTesting; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc.Controllers; + using Microsoft.AspNetCore.Routing; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + + /// + /// Asserts that every API endpoint in the ServiceControl host carries an explicit + /// authorization decision — either: + /// + /// with a Policy that is a member of — a specific permission is required + /// — reviewed: authenticated but no permission needed + /// — reviewed: public (e.g. health/metadata endpoints) + /// + /// + /// In Phase 0, no endpoints have a permission policy yet, so the entire + /// set is in the . As Phase 1+ wires permissions the baseline shrinks. + /// Any endpoint NOT in the baseline and NOT carrying one of the three recognized decisions fails the + /// test immediately, catching new endpoints that were added without a decision. + /// + class Every_endpoint_declares_an_authorization_decision : AcceptanceTest + { + /// + /// The Phase 0 baseline: all endpoints that do not yet have a permission declaration. + /// A route in this set is allowed to be "uncovered" — it was here before RBAC. + /// Remove routes from this list as Phase 1+ wires [Authorize(Policy = X)] where + /// X is a member of ; this set reaching empty is the + /// definition of "coverage complete". + /// + static readonly HashSet Phase0Baseline = + [ + // Authentication (AllowAnonymous — will never be in the baseline) + // These are here so the test can document what endpoints exist during Phase 0. + // Endpoints below lack [Authorize(Policy=X)] / [AuthenticatedOnly] / [AllowAnonymous]: + + // Message failures area — enforced on tf3651-authz-s2; removed from baseline. + // Retry: POST api/errors/{id}/retry, POST api/errors/retry, POST api/errors/retry/all, + // POST api/errors/queues/{queueAddress}/retry, POST api/errors/{name}/retry/all + // Errors GET/HEAD: GET api/errors, HEAD api/errors, GET api/errors/summary + // Error by ID: GET api/errors/{id}, GET api/errors/last/{id} + // Archive: PATCH/POST api/errors/{id}/archive, PATCH/POST api/errors/archive + // Unarchive: PATCH api/errors/unarchive, PATCH api/errors/{from}...{to}/unarchive + // Archive groups: GET api/errors/groups/{classifier?}, GET api/archive/groups/id/{groupId} + // Edit: GET api/edit/config, POST api/edit/{id} + // Pending retries: POST api/pendingretries/retry, POST api/pendingretries/queues/retry, + // PATCH api/pendingretries/resolve, PATCH api/pendingretries/queues/resolve + // Recoverability: GET/HEAD recoverability groups/errors, POST/DELETE comment, + // GET history, GET classifiers, POST retry/archive/unarchive + // Messages search: GET api/messages, GET api/messages2, GET api/messages/search, + // GET api/messages/search/{keyword}, GET api/messages/{id}/body, + // GET api/conversations/{conversationId}, GET api/endpoints/{e}/messages, + // GET api/endpoints/{e}/messages/search, GET api/endpoints/{e}/messages/search/{k}, + // GET api/endpoints/{e}/audit-count + + // Pending retries GET — these routes exist in the endpoint baseline but may be + // served by a separate controller or forwarded; kept in baseline until confirmed wired. + "GET api/pendingretries", + "GET api/pendingretries/{queue}", + + // Queue addresses — wired later + "GET api/errors/queues/addresses", + "GET api/errors/queues/addresses/search/{search}", + "DELETE api/errors/queues/{queueaddress}", + + // Recoverability unacknowledged groups — wired later + "DELETE api/recoverability/unacknowledgedgroups/{groupId:required:minlength(1)}", + + // Endpoints monitoring area — wired later + "GET api/endpoints", + "GET api/endpoints/{id}", + "GET api/endpoints/known", + "GET api/endpoints/{endpointname}/errors", + "GET api/heartbeatstatus", + "GET api/heartbeats/stats", + "PATCH api/endpoints/{endpointId}", + "DELETE api/endpoints/{endpointId}", + + // Endpoint settings — wired later + "GET api/endpointssettings", + "PATCH api/endpointssettings/{endpointName?}", + + // Custom checks — wired later + "GET api/customchecks", + "DELETE api/customchecks/{id}", + + // Sagas — wired later + "GET api/sagas/{id}", + + // Event log — wired later + "GET api/eventlogitems", + + // Licensing — wired later + "GET api/license", + "POST api/license", + "GET api/licensing/endpoints", + "POST api/licensing/endpoints/update", + "GET api/licensing/report/available", + "GET api/licensing/report/file", + "GET api/licensing/settings/info", + "GET api/licensing/settings/test", + "GET api/licensing/settings/masks", + "POST api/licensing/settings/masks/update", + + // Notifications — wired later + "GET api/notifications", + "GET api/notifications/email", + "POST api/notifications/email", + "POST api/notifications/email/toggle", + "POST api/notifications/email/test", + "DELETE api/notifications", + + // Message redirects — wired later + "GET api/redirects", + "POST api/redirects", + "HEAD api/redirect", + "PUT api/redirects/{messageRedirectId:guid}", + "DELETE api/redirects/{messageRedirectId:guid}", + + // Connections — wired later + "GET api/connection", + + // Failed errors / failed message retries — wired later + "GET api/failederrors/count", + "POST api/failederrors/import", + "GET api/failedmessageretries/count", + + // Internal / test endpoints — wired later + "GET api/test/knownendpoints/query", + "POST api/criticalerror/trigger", + + // Root (AllowAnonymous) — documented but not in baseline (they already pass) + // "GET api" + // "GET api/instance-info" + // "GET api/configuration" + // "GET api/configuration/remotes" + ]; + + [Test] + public async Task All_endpoints_have_a_reviewed_authorization_decision() + { + var uncoveredEndpoints = new List(); + var newUncoveredEndpoints = new List(); // NEW endpoints without a decision — always fail + + // Enumerate endpoints inside Done() while the host (and its IServiceProvider) is still alive. + // Accessing dataSource.Endpoints after Run() completes is unsafe when the authorization + // policy provider is registered — ASP.NET Core's RouteEndpointDataSource resolves + // endpoint metadata lazily via the service provider, which is disposed after Run(). + _ = await Define() + .Done(ctx => + { + var dataSources = Services.GetRequiredService>(); + + foreach (var dataSource in dataSources) + { + foreach (var endpoint in dataSource.Endpoints) + { + // Only check MVC controller actions + var actionDescriptor = endpoint.Metadata.GetMetadata(); + if (actionDescriptor == null) + { + continue; + } + + // Build a route key: "METHOD path/template" + var routePattern = (endpoint as RouteEndpoint)?.RoutePattern.RawText ?? "unknown"; + var httpMethods = endpoint.Metadata.GetOrderedMetadata() + .SelectMany(m => m.HttpMethods) + .Distinct() + .OrderBy(x => x) + .ToList(); + + var httpMethod = httpMethods.Count == 1 ? httpMethods[0] : string.Join("/", httpMethods); + var routeKey = $"{httpMethod} {routePattern}"; + + // Check for one of the three valid authorization decisions + if (HasAuthorizationDecision(endpoint, actionDescriptor)) + { + continue; // Covered — has a decision + } + + if (Phase0Baseline.Contains(routeKey)) + { + uncoveredEndpoints.Add(routeKey); // Known uncovered — in baseline, Phase 1+ will fix + } + else + { + newUncoveredEndpoints.Add(routeKey); // NEW endpoint without decision — fail now + } + } + } + + return Task.FromResult(true); + }) + .Run(); + + if (newUncoveredEndpoints.Count > 0) + { + Assert.Fail( + $"The following endpoints were added without an authorization decision. " + + $"Add [Authorize(Policy = )], [AuthenticatedOnly], or [AllowAnonymous] to each:\n" + + string.Join("\n", newUncoveredEndpoints.Select(e => $" - {e}"))); + } + + // Log the remaining baseline for visibility (informational, not a failure in Phase 0) + if (uncoveredEndpoints.Count > 0) + { + TestContext.Out.WriteLine( + $"[Phase 0 baseline] {uncoveredEndpoints.Count} endpoint(s) still need authorization wiring:\n" + + string.Join("\n", uncoveredEndpoints.Select(e => $" - {e}"))); + } + } + + /// + /// Returns true if the endpoint has one of the recognized authorization decisions: + /// + /// [Authorize(Policy = X)] where X is a member of + /// — reviewed: authenticated but no specific permission needed + /// — reviewed: public endpoint + /// + /// Checks both class-level and method-level attributes (method-level takes precedence in MVC). + /// + static bool HasAuthorizationDecision(Microsoft.AspNetCore.Http.Endpoint endpoint, ControllerActionDescriptor descriptor) + { + var controllerType = descriptor.ControllerTypeInfo; + var methodInfo = descriptor.MethodInfo; + + // [Authorize(Policy = X)] where X is a known permission — Phase 1+ enforcement + if (HasPermissionPolicy(endpoint.Metadata.GetOrderedMetadata()) || + HasPermissionPolicy(controllerType.GetCustomAttributes(inherit: true)) || + HasPermissionPolicy(methodInfo.GetCustomAttributes(inherit: true))) + { + return true; + } + + // AuthenticatedOnlyAttribute — reviewed: no specific permission needed + if (endpoint.Metadata.GetMetadata() != null || + controllerType.GetCustomAttribute() != null || + methodInfo.GetCustomAttribute() != null) + { + return true; + } + + // AllowAnonymousAttribute — reviewed: public endpoint + if (endpoint.Metadata.GetMetadata() != null || + controllerType.GetCustomAttribute() != null || + methodInfo.GetCustomAttribute() != null) + { + return true; + } + + return false; + } + + /// + /// Returns true if any of the given instances carries a + /// Policy that is a member of . + /// + static bool HasPermissionPolicy(IEnumerable attributes) => + attributes.Any(a => a.Policy != null && Permissions.All.Contains(a.Policy)); + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_archiving_messages_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_archiving_messages_s2.cs new file mode 100644 index 0000000000..c8fab941e2 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_archiving_messages_s2.cs @@ -0,0 +1,332 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.Auth; + using AcceptanceTesting.OpenIdConnect; + using Contracts.Operations; + using MessageFailures; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + using ServiceControl.Persistence; + + /// + /// S2 RBAC enforcement tests for message archive/unarchive and edit-and-retry: + /// - PATCH/POST api/errors/{id}/archive (messages:archive) + /// - PATCH/POST api/errors/archive (messages:archive, batch) + /// - PATCH api/errors/unarchive (messages:unarchive, batch) + /// - GET api/edit/config (messages:edit) + /// - POST api/edit/{id} (messages:edit) + /// + /// Test matrix: (a) permitted → 2xx, (b) no-perm → 403, (c) decision logged, (d) OIDC disabled → 2xx + /// + class When_archiving_messages_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + const string SalesQueueAddress = "Sales.OrderHandler@localhost"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + // ── Single-message archive ────────────────────────────────────────────────── + + [Test] + public async Task Archive_single_operator_receives_2xx() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Post($"/api/errors/{messageId}/archive", token); + return response != null; + }) + .Run(); + + Assert.That((int)response.StatusCode, Is.InRange(200, 299)); + } + + [Test] + public async Task Archive_single_viewer_without_archive_receives_403() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + response = await Post($"/api/errors/{messageId}/archive", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task Archive_single_deny_decision_is_logged() + { + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + await Post($"/api/errors/{messageId}/archive", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("messages:archive") && e.Message.Contains("deny"))); + } + + [Test] + public async Task Archive_single_allow_decision_is_logged() + { + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + await Post($"/api/errors/{messageId}/archive", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("messages:archive") && e.Message.Contains("allow"))); + } + + // ── Batch archive ──────────────────────────────────────────────────────────── + + [Test] + public async Task Archive_batch_operator_receives_2xx() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + var body = new StringContent($"[\"{messageId}\"]", Encoding.UTF8, "application/json"); + response = await PostWithBody("/api/errors/archive", token, body); + return response != null; + }) + .Run(); + + Assert.That((int)response.StatusCode, Is.InRange(200, 299)); + } + + [Test] + public async Task Archive_batch_viewer_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + var body = new StringContent("[]", Encoding.UTF8, "application/json"); + response = await PostWithBody("/api/errors/archive", token, body); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Unarchive batch ────────────────────────────────────────────────────────── + + [Test] + public async Task Unarchive_batch_operator_receives_2xx() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + using var request = new HttpRequestMessage(HttpMethod.Patch, "/api/errors/unarchive"); + request.Headers.Authorization = OpenIdConnectAssertions.CreateBearerToken(token); + request.Content = new StringContent("[]", Encoding.UTF8, "application/json"); + response = await HttpClient.SendAsync(request); + return response != null; + }) + .Run(); + + // empty ids → BadRequest(400) is still a successful auth check (gets past 403) + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task Unarchive_batch_viewer_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + using var request = new HttpRequestMessage(HttpMethod.Patch, "/api/errors/unarchive"); + request.Headers.Authorization = OpenIdConnectAssertions.CreateBearerToken(token); + request.Content = new StringContent("[]", Encoding.UTF8, "application/json"); + response = await HttpClient.SendAsync(request); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Edit config GET ────────────────────────────────────────────────────────── + + [Test] + public async Task EditConfig_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Get, "/api/edit/config", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task EditConfig_viewer_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Get, "/api/edit/config", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── OIDC disabled ───────────────────────────────────────────────────────────── + + [Test] + public async Task Archive_oidc_disabled_accepts_without_token() + { + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + response = await OpenIdConnectAssertions.SendRequestWithoutAuth(HttpClient, HttpMethod.Post, $"/api/errors/{messageId}/archive"); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + Task StoreFailedMessage(string messageId, string queueAddress) + { + var dataStore = Services.GetRequiredService(); + return dataStore.StoreFailedMessagesForTestsOnly(BuildFailedMessage(messageId, queueAddress)); + } + + Task Post(string path, string token) => + OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Post, path, token); + + async Task PostWithBody(string path, string token, HttpContent content) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.Authorization = OpenIdConnectAssertions.CreateBearerToken(token); + request.Content = content; + return await HttpClient.SendAsync(request); + } + + static FailedMessage BuildFailedMessage(string uniqueMessageId, string queueAddress) => + new() + { + Id = $"FailedMessages/{uniqueMessageId}", + UniqueMessageId = uniqueMessageId, + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + AttemptedAt = DateTime.UtcNow, + MessageId = uniqueMessageId, + FailureDetails = new FailureDetails { AddressOfFailingEndpoint = queueAddress, TimeOfFailure = DateTime.UtcNow }, + Headers = new Dictionary + { + ["NServiceBus.MessageId"] = uniqueMessageId, + ["NServiceBus.FailedQ"] = queueAddress + } + } + ] + }; + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_authorization_is_disabled.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_authorization_is_disabled.cs new file mode 100644 index 0000000000..2474299eac --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_authorization_is_disabled.cs @@ -0,0 +1,83 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Verifies that when OIDC authentication (and therefore authorization) is disabled, + /// the GET /api/me/permissions endpoint is still accessible and the rest of + /// the API behaves exactly as before authorization was added (non-breaking guarantee, + /// spec §4). + /// + class When_authorization_is_disabled : AcceptanceTest + { + OpenIdConnectTestConfiguration configuration; + + [SetUp] + public void DisableAuth() + { + // Explicitly disable OIDC so the non-breaking guarantee is exercised + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + } + + [Test] + public async Task Api_endpoints_are_accessible_without_authentication() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + // /api/errors is not [AllowAnonymous], but with auth disabled it should be + // freely accessible (unchanged from pre-RBAC behaviour) + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, + HttpMethod.Get, + "/api/errors"); + + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertNoAuthenticationRequired(response); + } + + [Test] + public async Task Me_permissions_endpoint_returns_404_when_auth_disabled() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + // When OIDC is disabled, IPermissionEvaluator is not registered in DI. + // MePermissionsController resolves it optionally and returns 404 so the + // endpoint is effectively absent — non-breaking guarantee, spec §4. + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, + HttpMethod.Get, + "/api/me/permissions"); + + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound), + "With OIDC disabled, /api/me/permissions must return 404 (endpoint does not exist in this deployment)"); + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_operating_on_recoverability_groups_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_operating_on_recoverability_groups_s2.cs new file mode 100644 index 0000000000..139ab3bfb3 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_operating_on_recoverability_groups_s2.cs @@ -0,0 +1,477 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.Auth; + using AcceptanceTesting.OpenIdConnect; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// S2 RBAC enforcement tests for the recoverability group endpoints: + /// - GET api/recoverability/groups/{classifier?} (recoverabilitygroups:view) + /// - GET api/recoverability/groups/{id}/errors (recoverabilitygroups:view, R1 paged) + /// - HEAD api/recoverability/groups/{id}/errors (recoverabilitygroups:view) + /// - GET api/recoverability/groups/id/{id} (recoverabilitygroups:view) + /// - GET api/recoverability/history (recoverabilitygroups:view) + /// - GET api/recoverability/classifiers (recoverabilitygroups:view) + /// - POST api/recoverability/groups/{id}/comment (recoverabilitygroups:view) + /// - DELETE api/recoverability/groups/{id}/comment (recoverabilitygroups:view) + /// - POST api/recoverability/groups/{id}/errors/retry (recoverabilitygroups:retry) + /// - POST api/recoverability/groups/{id}/errors/archive (recoverabilitygroups:archive) + /// - POST api/recoverability/groups/{id}/errors/unarchive (recoverabilitygroups:unarchive) + /// + /// Special: group write operations fail-closed for scoped users (v1 limitation). + /// + class When_operating_on_recoverability_groups_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + // ── GET api/recoverability/groups ──────────────────────────────────────────── + + [Test] + public async Task GetAllGroups_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/recoverability/groups", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task GetAllGroups_no_permission_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/recoverability/groups", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task GetAllGroups_deny_decision_is_logged() + { + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + await Get("/api/recoverability/groups", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("recoverabilitygroups:view") && e.Message.Contains("deny"))); + } + + [Test] + public async Task GetAllGroups_allow_decision_is_logged() + { + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + await Get("/api/recoverability/groups", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("recoverabilitygroups:view") && e.Message.Contains("allow"))); + } + + // ── GET api/recoverability/classifiers ──────────────────────────────────────── + + [Test] + public async Task GetClassifiers_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/recoverability/classifiers", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task GetClassifiers_no_permission_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/recoverability/classifiers", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── GET api/recoverability/history ──────────────────────────────────────────── + + [Test] + public async Task GetHistory_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/recoverability/history", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task GetHistory_no_permission_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/recoverability/history", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── POST api/recoverability/groups/{id}/errors/retry (fail-closed) ──────────── + + [Test] + public async Task GroupRetry_operator_unrestricted_receives_2xx() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/retry", token); + return response != null; + }) + .Run(); + + Assert.That((int)response.StatusCode, Is.InRange(200, 299)); + } + + [Test] + public async Task GroupRetry_no_permission_receives_403() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/retry", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task GroupRetry_scoped_user_receives_403_fail_closed() + { + // Scoped users are denied fail-closed because groups span multiple queues and + // cannot be scope-checked per-queue. This is a v1 documented limitation. + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedGroupRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("scoped-op", ["scoped-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/retry", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), + "A scoped user must be denied fail-closed for group operations"); + } + + // ── POST api/recoverability/groups/{id}/errors/archive ──────────────────────── + + [Test] + public async Task GroupArchive_operator_receives_2xx() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/archive", token); + return response != null; + }) + .Run(); + + Assert.That((int)response.StatusCode, Is.InRange(200, 299)); + } + + [Test] + public async Task GroupArchive_no_permission_receives_403() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/archive", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task GroupArchive_scoped_user_receives_403_fail_closed() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedGroupRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("scoped-op", ["scoped-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/archive", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── POST api/recoverability/groups/{id}/errors/unarchive ────────────────────── + + [Test] + public async Task GroupUnarchive_operator_receives_2xx() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/unarchive", token); + return response != null; + }) + .Run(); + + Assert.That((int)response.StatusCode, Is.InRange(200, 299)); + } + + [Test] + public async Task GroupUnarchive_no_permission_receives_403() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/unarchive", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task GroupUnarchive_scoped_user_receives_403_fail_closed() + { + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedGroupRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("scoped-op", ["scoped-operator"]); + response = await Post($"/api/recoverability/groups/{groupId}/errors/unarchive", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── OIDC disabled ───────────────────────────────────────────────────────────── + + [Test] + public async Task GroupRetry_oidc_disabled_accepts_without_token() + { + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + var groupId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth(HttpClient, HttpMethod.Post, + $"/api/recoverability/groups/{groupId}/errors/retry"); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + Task Get(string path, string token) => + OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Get, path, token); + + Task Post(string path, string token) => + OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Post, path, token); + + /// + /// RBAC config with a scoped-operator that has scoped (queue-restricted) grants + /// for group retry/archive/unarchive. Used to verify the fail-closed behaviour. + /// + sealed class ScopedGroupRbacConfiguration : IDisposable + { + readonly string tempYamlPath; + bool disposed; + + public ScopedGroupRbacConfiguration() + { + const string scopedYaml = """ + schemaVersion: 1 + roles: + sc-admin: + bindings: [ "role:sc-admin" ] + permissions: [ "*" ] + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + - "recoverabilitygroups:retry" + - "recoverabilitygroups:archive" + - "recoverabilitygroups:unarchive" + sc-viewer: + bindings: [ "role:sc-viewer" ] + permissions: + - "recoverabilitygroups:view" + scoped-operator: + bindings: [ "role:scoped-operator" ] + permissions: + - permission: "recoverabilitygroups:retry" + scope: { allow: ["Sales.*"] } + - permission: "recoverabilitygroups:archive" + scope: { allow: ["Sales.*"] } + - permission: "recoverabilitygroups:unarchive" + scope: { allow: ["Sales.*"] } + """; + + tempYamlPath = Path.Combine(Path.GetTempPath(), $"rbac-test-{Guid.NewGuid():N}.yaml"); + File.WriteAllText(tempYamlPath, scopedYaml); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", tempYamlPath); + } + + public void Dispose() + { + if (!disposed) + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", null); + if (File.Exists(tempYamlPath)) + { + File.Delete(tempYamlPath); + } + disposed = true; + } + } + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_reading_failed_messages_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_reading_failed_messages_s2.cs new file mode 100644 index 0000000000..cca1d6a8ac --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_reading_failed_messages_s2.cs @@ -0,0 +1,473 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.Auth; + using AcceptanceTesting.OpenIdConnect; + using Contracts.Operations; + using MessageFailures; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + using ServiceControl.Operations; + using ServiceControl.Persistence; + + /// + /// S2 RBAC enforcement tests for failed-message read endpoints: + /// - GET api/errors (ErrorsGet) + /// - GET api/errors/summary (ErrorsSummary) + /// - GET api/errors/{id} (ErrorBy) + /// - GET api/errors/last/{id} (ErrorLastBy) + /// - GET api/endpoints/{name}/errors (ErrorsByEndpointName) + /// + /// Test matrix per endpoint: + /// (a) permitted role (sc-operator has messages:view) → 200 + /// (b) unpermitted role (no messages:view) → 403 + /// (c) scoped role — in-scope → 200, out-of-scope (for single-message endpoints) → 403 + /// (d) allow and deny decisions logged in ServiceControl.Audit + /// (e) OIDC disabled → endpoint accepts without auth + /// + class When_reading_failed_messages_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + const string SalesQueueAddress = "Sales.OrderHandler@localhost"; + const string FinanceQueueAddress = "Finance.Payments@localhost"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + // ── GET api/errors ────────────────────────────────────────────────────────── + + [Test] + public async Task ErrorsGet_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/errors", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ErrorsGet_role_without_view_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + // Role with no permissions at all + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/errors", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task ErrorsGet_scoped_role_returns_filtered_results() + { + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(Guid.NewGuid().ToString("N"), SalesQueueAddress); + await StoreFailedMessage(Guid.NewGuid().ToString("N"), FinanceQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles("sales-user", ["sales-operator"]); + response = await Get("/api/errors", token); + return response != null; + }) + .Run(); + + // A scoped user gets a 200 but only sees in-scope messages (paging total is filtered too) + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ErrorsGet_deny_decision_is_logged() + { + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("anon", ["sc-no-perms"]); + await Get("/api/errors", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("messages:view") && e.Message.Contains("deny"))); + } + + [Test] + public async Task ErrorsGet_allow_decision_is_logged() + { + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("op", ["sc-operator"]); + await Get("/api/errors", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("messages:view") && e.Message.Contains("allow"))); + } + + [Test] + public async Task ErrorsGet_oidc_disabled_returns_200_without_token() + { + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth(HttpClient, HttpMethod.Get, "/api/errors"); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + // ── GET api/errors/summary ────────────────────────────────────────────────── + + [Test] + public async Task ErrorsSummary_operator_passes_auth_gate() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/errors/summary", token); + return response != null; + }) + .Run(); + + // The summary endpoint uses a RavenDB facet index that may not be populated in the + // test environment — any non-403 response confirms the auth gate was passed. + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task ErrorsSummary_no_permission_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/errors/summary", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── GET api/errors/{id} ──────────────────────────────────────────────────── + + [Test] + public async Task ErrorById_operator_receives_200() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get($"/api/errors/{messageId}", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ErrorById_no_permission_receives_403() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get($"/api/errors/{messageId}", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task ErrorById_scoped_in_scope_receives_200() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("sales-user", ["sales-operator"]); + response = await Get($"/api/errors/{messageId}", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ErrorById_scoped_out_of_scope_receives_403() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, FinanceQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("sales-user", ["sales-operator"]); + response = await Get($"/api/errors/{messageId}", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task ErrorById_scoped_deny_logged_with_queue_address() + { + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, FinanceQueueAddress); + var token = mockOidcServer.GenerateTokenWithRealmRoles("sales-audit", ["sales-operator"]); + await Get($"/api/errors/{messageId}", token); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => + e.Message.Contains("messages:view") + && e.Message.Contains("deny") + && e.Message.Contains(FinanceQueueAddress))); + } + + // ── GET api/endpoints/{name}/errors ─────────────────────────────────────── + + [Test] + public async Task ErrorsByEndpoint_operator_receives_200() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await Get("/api/endpoints/Sales.OrderHandler/errors", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task ErrorsByEndpoint_no_permission_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await Get("/api/endpoints/Sales.OrderHandler/errors", token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + Task StoreFailedMessage(string messageId, string queueAddress) + { + var dataStore = Services.GetRequiredService(); + return dataStore.StoreFailedMessagesForTestsOnly(BuildFailedMessage(messageId, queueAddress)); + } + + Task Get(string path, string token) => + OpenIdConnectAssertions.SendRequestWithBearerToken(HttpClient, HttpMethod.Get, path, token); + + static FailedMessage BuildFailedMessage(string uniqueMessageId, string queueAddress) + { + // Build the minimal MessageMetadata required by FailedMessageViewIndex and + // FailedMessageViewTransformer so that GET /api/errors can deserialise the results. + var endpointName = queueAddress.Split('@')[0]; // e.g. "Sales.OrderHandler" + var receivingEndpoint = new EndpointDetails { Name = endpointName, Host = "localhost", HostId = Guid.Empty }; + var metadata = new Dictionary + { + ["MessageId"] = uniqueMessageId, + ["MessageType"] = "TestMessage", + ["IsSystemMessage"] = false, + ["TimeSent"] = DateTime.UtcNow, + ["ReceivingEndpoint"] = receivingEndpoint, + ["SendingEndpoint"] = receivingEndpoint, + }; + + return new FailedMessage + { + Id = $"FailedMessages/{uniqueMessageId}", + UniqueMessageId = uniqueMessageId, + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + AttemptedAt = DateTime.UtcNow, + MessageId = uniqueMessageId, + MessageMetadata = metadata, + FailureDetails = new FailureDetails + { + AddressOfFailingEndpoint = queueAddress, + TimeOfFailure = DateTime.UtcNow + }, + Headers = new Dictionary + { + ["NServiceBus.MessageId"] = uniqueMessageId, + ["NServiceBus.FailedQ"] = queueAddress + } + } + ] + }; + } + + sealed class ScopedRbacConfiguration : IDisposable + { + readonly string tempYamlPath; + bool disposed; + + public ScopedRbacConfiguration() + { + const string scopedYaml = """ + schemaVersion: 1 + roles: + sc-admin: + bindings: [ "role:sc-admin" ] + permissions: [ "*" ] + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + - "messages:view" + sc-viewer: + bindings: [ "role:sc-viewer" ] + permissions: + - "messages:view" + sales-operator: + bindings: [ "role:sales-operator" ] + permissions: + - permission: "messages:view" + scope: { allow: ["Sales.*"] } + """; + + tempYamlPath = Path.Combine(Path.GetTempPath(), $"rbac-test-{Guid.NewGuid():N}.yaml"); + File.WriteAllText(tempYamlPath, scopedYaml); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", tempYamlPath); + } + + public void Dispose() + { + if (!disposed) + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", null); + if (File.Exists(tempYamlPath)) + { + File.Delete(tempYamlPath); + } + disposed = true; + } + } + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_requesting_my_permissions.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_requesting_my_permissions.cs new file mode 100644 index 0000000000..fad0bc2277 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_requesting_my_permissions.cs @@ -0,0 +1,127 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System.Net; + using System.Net.Http; + using System.Text.Json; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// Acceptance tests for the GET /api/me/permissions descriptor endpoint (spec §5.4). + /// The endpoint returns the calling user's effective permission set so ServicePulse can + /// decide which UI controls to enable. + /// + class When_requesting_my_permissions : AcceptanceTest + { + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + [Test] + public async Task Returns_the_effective_permission_set_for_operator_role() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + // Issue a token carrying the sc-operator realm role + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "alice", + realmRoles: ["sc-operator"]); + + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, + HttpMethod.Get, + "/api/me/permissions", + token); + + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + "Authenticated operator should receive 200"); + + var body = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + // descriptor must include version, user, and permissions array + Assert.That(root.TryGetProperty("version", out _), Is.True, + "Response must contain 'version' field"); + Assert.That(root.TryGetProperty("user", out var userProp), Is.True, + "Response must contain 'user' field"); + Assert.That(userProp.GetString(), Is.EqualTo("alice")); + + Assert.That(root.TryGetProperty("permissions", out var permsProp), Is.True, + "Response must contain 'permissions' array"); + Assert.That(permsProp.ValueKind, Is.EqualTo(JsonValueKind.Array)); + + // sc-operator should have messages:retry per the default rbac.yaml + var hasRetry = false; + foreach (var perm in permsProp.EnumerateArray()) + { + if (perm.TryGetProperty("permission", out var p) && + p.GetString() == "messages:retry") + { + hasRetry = true; + break; + } + } + Assert.That(hasRetry, Is.True, + "sc-operator effective permissions must include messages:retry"); + } + + [Test] + public async Task Unauthenticated_request_receives_401() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, + HttpMethod.Get, + "/api/me/permissions"); + + return response != null; + }) + .Run(); + + OpenIdConnectAssertions.AssertUnauthorized(response); + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_resolving_pending_retries_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_resolving_pending_retries_s2.cs new file mode 100644 index 0000000000..a59d15fd14 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_resolving_pending_retries_s2.cs @@ -0,0 +1,199 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System.Net; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.Auth; + using AcceptanceTesting.OpenIdConnect; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// S2 RBAC enforcement tests for pending-retry and resolve endpoints: + /// - POST api/pendingretries/retry (messages:retry) + /// - POST api/pendingretries/queues/retry (messages:retry) + /// - PATCH api/pendingretries/resolve (messages:retry) + /// - PATCH api/pendingretries/queues/resolve (messages:retry) + /// + /// Test matrix: (a) permitted (sc-operator) → 2xx, (b) no-perm (sc-viewer) → 403, + /// (c) decision logged, (d) OIDC disabled → 2xx + /// + class When_resolving_pending_retries_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + // ── POST pendingretries/retry ───────────────────────────────────────────────── + + [Test] + public async Task PendingRetriesRetry_operator_receives_2xx() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + var body = new StringContent("[]", Encoding.UTF8, "application/json"); + response = await PostWithBody("/api/pendingretries/retry", token, body); + return response != null; + }) + .Run(); + + // Empty ids → 422 UnprocessableEntity — that's past the 403 gate, which is what matters + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task PendingRetriesRetry_viewer_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + var body = new StringContent("[]", Encoding.UTF8, "application/json"); + response = await PostWithBody("/api/pendingretries/retry", token, body); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task PendingRetriesRetry_deny_decision_is_logged() + { + var recordingProvider = new RecordingLoggerProvider(); + CustomizeHostBuilder = hb => hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + var body = new StringContent("[]", Encoding.UTF8, "application/json"); + await PostWithBody("/api/pendingretries/retry", token, body); + return true; + }) + .Run(); + + Assert.That(recordingProvider.EntriesFor("ServiceControl.Audit"), + Has.Some.Matches(e => e.Message.Contains("messages:retry") && e.Message.Contains("deny"))); + } + + // ── PATCH pendingretries/resolve ────────────────────────────────────────────── + + [Test] + public async Task Resolve_operator_receives_2xx() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + var json = """{"uniquemessageids":["some-id"]}"""; + response = await PatchWithBody("/api/pendingretries/resolve", token, json); + return response != null; + }) + .Run(); + + // Accepted or some 2xx + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task Resolve_viewer_receives_403() + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("bob", ["sc-viewer"]); + var json = """{"uniquemessageids":["some-id"]}"""; + response = await PatchWithBody("/api/pendingretries/resolve", token, json); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── OIDC disabled ───────────────────────────────────────────────────────────── + + [Test] + public async Task PendingRetries_oidc_disabled_accepts_without_token() + { + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var body = new StringContent("[]", Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/pendingretries/retry"); + request.Content = body; + response = await HttpClient.SendAsync(request); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.Forbidden)); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + async Task PostWithBody(string path, string token, HttpContent body) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.Authorization = OpenIdConnectAssertions.CreateBearerToken(token); + request.Content = body; + return await HttpClient.SendAsync(request); + } + + async Task PatchWithBody(string path, string token, string json) + { + using var request = new HttpRequestMessage(HttpMethod.Patch, path); + request.Headers.Authorization = OpenIdConnectAssertions.CreateBearerToken(token); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return await HttpClient.SendAsync(request); + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_retrying_a_failed_message_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_retrying_a_failed_message_s2.cs new file mode 100644 index 0000000000..28b5de5b5c --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_retrying_a_failed_message_s2.cs @@ -0,0 +1,421 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.Auth; + using AcceptanceTesting.OpenIdConnect; + using Contracts.Operations; + using MessageFailures; + using Microsoft.Extensions.DependencyInjection; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + using ServiceControl.Persistence; + + /// + /// Acceptance tests for the S2 RBAC enforcement on POST api/errors/{id}/retry. + /// S2 differs from S3 in that the resource-scope check is performed inline in the controller + /// via rather + /// than through a typed IAuthorizationHandler. + /// + /// Covers the full test matrix: + /// (a) permitted role → 202 Accepted + /// (b) unpermitted role → 403 Forbidden + /// (c) scoped role → in-scope 202, out-of-scope 403 + /// (d) allow AND deny decisions appear in the ServiceControl.Audit log + /// — out-of-scope deny must include the queue address in the log entry + /// (e) OIDC disabled → endpoint is accessible without auth (returns 202 Accepted) + /// + class When_retrying_a_failed_message_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + const string SalesQueueAddress = "Sales.OrderHandler@localhost"; + const string FinanceQueueAddress = "Finance.Payments@localhost"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + // ----------------------------------------------------------------------- + // (a) sc-operator (has messages:retry) → 202 Accepted + // ----------------------------------------------------------------------- + + [Test] + public async Task Operator_with_retry_permission_receives_202() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "operator-alice", + realmRoles: ["sc-operator"]); + + response = await SendRetry(token, messageId); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted), + "sc-operator with messages:retry should receive 202 Accepted"); + } + + // ----------------------------------------------------------------------- + // (b) sc-viewer (no messages:retry) → 403 Forbidden + // ----------------------------------------------------------------------- + + [Test] + public async Task Viewer_without_retry_permission_receives_403() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "viewer-bob", + realmRoles: ["sc-viewer"]); + + response = await SendRetry(token, messageId); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), + "sc-viewer without messages:retry should receive 403 Forbidden"); + } + + // ----------------------------------------------------------------------- + // (c) Scoped role: in-scope → 202, out-of-scope → 403 + // ----------------------------------------------------------------------- + + [Test] + public async Task Scoped_role_in_scope_receives_202() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "sales-operator", + realmRoles: ["sales-operator"]); + + response = await SendRetry(token, messageId); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted), + "sales-operator with scoped retry permission should receive 202 for in-scope queue"); + } + + [Test] + public async Task Scoped_role_out_of_scope_receives_403() + { + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + // Finance queue is outside sales-operator's scope + await StoreFailedMessage(messageId, FinanceQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "sales-operator-out", + realmRoles: ["sales-operator"]); + + response = await SendRetry(token, messageId); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), + "sales-operator with scoped retry permission should receive 403 for out-of-scope queue"); + } + + // ----------------------------------------------------------------------- + // (d) Decision logging: both allow and deny appear in ServiceControl.Audit log + // ----------------------------------------------------------------------- + + [Test] + public async Task Allow_decision_is_logged_to_ServiceControl_Audit_category() + { + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + + CustomizeHostBuilder = hb => + hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "operator-logged", + realmRoles: ["sc-operator"]); + + await SendRetry(token, messageId); + return true; + }) + .Run(); + + var auditEntries = recordingProvider.EntriesFor("ServiceControl.Audit"); + + // The resource-scope allow decision must be present, identifiable by the queue address. + Assert.That(auditEntries, Has.Some.Matches(e => + e.Message.Contains("messages:retry") + && e.Message.Contains("allow") + && e.Message.Contains(SalesQueueAddress)), + "A resource-scope allow decision for messages:retry must appear in ServiceControl.Audit log " + + "and must include the queue address as the resource"); + } + + [Test] + public async Task Deny_decision_for_out_of_scope_is_logged_with_queue_as_resource() + { + // This test verifies that the resource-scope deny (not just the verb-stage deny) + // is logged when a scoped role is denied due to queue address being out of scope. + // The resource-scope log is identifiable by the queue address appearing in the log entry. + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + + CustomizeHostBuilder = hb => + hb.Services.AddSingleton(recordingProvider); + + using var scopedConfig = new ScopedRbacConfiguration(); + + _ = await Define() + .Done(async ctx => + { + // Finance queue is outside sales-operator's scope + await StoreFailedMessage(messageId, FinanceQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "sales-operator-audit", + realmRoles: ["sales-operator"]); + + await SendRetry(token, messageId); + return true; + }) + .Run(); + + var auditEntries = recordingProvider.EntriesFor("ServiceControl.Audit"); + + // The resource-scope deny must include the queue address as the resource field. + Assert.That(auditEntries, Has.Some.Matches(e => + e.Message.Contains("messages:retry") + && e.Message.Contains("deny") + && e.Message.Contains(FinanceQueueAddress)), + "A resource-scope deny decision for messages:retry must appear in ServiceControl.Audit log " + + "and must include the out-of-scope queue address as the resource"); + } + + [Test] + public async Task Deny_decision_is_logged_to_ServiceControl_Audit_category() + { + // Verb-stage deny: a user without the permission at all (sc-viewer). + var messageId = Guid.NewGuid().ToString("N"); + var recordingProvider = new RecordingLoggerProvider(); + + CustomizeHostBuilder = hb => + hb.Services.AddSingleton(recordingProvider); + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + + var token = mockOidcServer.GenerateTokenWithRealmRoles( + subject: "viewer-denied", + realmRoles: ["sc-viewer"]); + + await SendRetry(token, messageId); + return true; + }) + .Run(); + + var auditEntries = recordingProvider.EntriesFor("ServiceControl.Audit"); + Assert.That(auditEntries, Has.Some.Matches(e => + e.Message.Contains("messages:retry") && e.Message.Contains("deny")), + "A deny decision for messages:retry must appear in ServiceControl.Audit log"); + } + + // ----------------------------------------------------------------------- + // (e) OIDC disabled → endpoint accessible without auth (non-breaking guarantee) + // ----------------------------------------------------------------------- + + [Test] + public async Task When_auth_disabled_retry_endpoint_accepts_without_token() + { + // Override: disable auth for this test + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + var messageId = Guid.NewGuid().ToString("N"); + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + await StoreFailedMessage(messageId, SalesQueueAddress); + // No bearer token — send unauthenticated request + response = await OpenIdConnectAssertions.SendRequestWithoutAuth( + HttpClient, + HttpMethod.Post, + $"/api/errors/{messageId}/retry"); + + return response != null; + }) + .Run(); + + // With auth disabled, the endpoint behaves as pre-RBAC: it accepts without a token. + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted), + "With OIDC disabled, the retry endpoint must accept unauthenticated requests with 202 Accepted"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + Task StoreFailedMessage(string messageId, string queueAddress) + { + var dataStore = Services.GetRequiredService(); + var message = BuildFailedMessage(messageId, queueAddress); + return dataStore.StoreFailedMessagesForTestsOnly(message); + } + + Task SendRetry(string token, string messageId) => + OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, + HttpMethod.Post, + $"/api/errors/{messageId}/retry", + token); + + static FailedMessage BuildFailedMessage(string uniqueMessageId, string queueAddress) => + new() + { + Id = $"FailedMessages/{uniqueMessageId}", + UniqueMessageId = uniqueMessageId, + Status = FailedMessageStatus.Unresolved, + ProcessingAttempts = + [ + new FailedMessage.ProcessingAttempt + { + AttemptedAt = DateTime.UtcNow, + MessageId = uniqueMessageId, + FailureDetails = new FailureDetails + { + AddressOfFailingEndpoint = queueAddress, + TimeOfFailure = DateTime.UtcNow + }, + Headers = new Dictionary + { + ["NServiceBus.MessageId"] = uniqueMessageId, + ["NServiceBus.FailedQ"] = queueAddress + } + } + ] + }; + + /// + /// Temporarily sets a scoped rbac.yaml that includes a "sales-operator" role + /// restricted to the Sales.* queue prefix. + /// + sealed class ScopedRbacConfiguration : IDisposable + { + readonly string tempYamlPath; + bool disposed; + + public ScopedRbacConfiguration() + { + const string scopedYaml = """ + schemaVersion: 1 + roles: + sc-admin: + bindings: [ "role:sc-admin" ] + permissions: [ "*" ] + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + - "messages:view" + - "messages:retry" + sc-viewer: + bindings: [ "role:sc-viewer" ] + permissions: + - "messages:view" + sales-operator: + bindings: [ "role:sales-operator" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["Sales.*"] } + """; + + tempYamlPath = Path.Combine(Path.GetTempPath(), $"rbac-test-{Guid.NewGuid():N}.yaml"); + File.WriteAllText(tempYamlPath, scopedYaml); + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", tempYamlPath); + } + + public void Dispose() + { + if (!disposed) + { + Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_RBACPOLICYFILE", null); + if (File.Exists(tempYamlPath)) + { + File.Delete(tempYamlPath); + } + disposed = true; + } + } + } + + class Context : ScenarioContext; + } +} diff --git a/src/ServiceControl.AcceptanceTests/Security/Authorization/When_searching_messages_s2.cs b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_searching_messages_s2.cs new file mode 100644 index 0000000000..94e930ddcd --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Security/Authorization/When_searching_messages_s2.cs @@ -0,0 +1,129 @@ +namespace ServiceControl.AcceptanceTests.Security.Authorization +{ + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.OpenIdConnect; + using NServiceBus.AcceptanceTesting; + using NUnit.Framework; + + /// + /// S2 RBAC enforcement tests for the messages search and conversation endpoints: + /// - GET api/messages (messages:view) + /// - GET api/messages2 (messages:view) + /// - GET api/messages/search (messages:view) + /// - GET api/messages/search/{keyword} (messages:view) + /// - GET api/conversations/{conversationId} (messages:view) + /// - GET api/endpoints/{ep}/messages (messages:view) + /// - GET api/endpoints/{ep}/messages/search (messages:view) + /// + /// Test matrix: (a) permitted → 200, (b) no-perm → 403, (c) OIDC disabled → 200 + /// + class When_searching_messages_s2 : AcceptanceTest + { + const string TestAudience = "api://test-audience"; + const string TestClientId = "test-client-id"; + const string TestApiScopes = "api://test-audience/.default"; + + OpenIdConnectTestConfiguration configuration; + MockOidcServer mockOidcServer; + + [SetUp] + public void ConfigureAuth() + { + mockOidcServer = new MockOidcServer(audience: TestAudience); + mockOidcServer.Start(); + + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithConfigurationValidationDisabled() + .WithAuthenticationEnabled() + .WithAuthority(mockOidcServer.Authority) + .WithAudience(TestAudience) + .WithServicePulseClientId(TestClientId) + .WithServicePulseApiScopes(TestApiScopes) + .WithRequireHttpsMetadata(false); + } + + [TearDown] + public void CleanupAuth() + { + configuration?.Dispose(); + mockOidcServer?.Dispose(); + } + + static readonly (string path, string description)[] MessagesViewPaths = + [ + ("/api/messages", "GET api/messages"), + ("/api/messages2", "GET api/messages2"), + ("/api/messages/search?q=test", "GET api/messages/search"), + ("/api/messages/search/test", "GET api/messages/search/{keyword}"), + ("/api/conversations/conv-123", "GET api/conversations/{id}"), + ("/api/endpoints/Sales.OrderHandler/messages", "GET api/endpoints/{ep}/messages"), + ("/api/endpoints/Sales.OrderHandler/messages/search?q=x", "GET api/endpoints/{ep}/messages/search"), + ("/api/endpoints/Sales.OrderHandler/messages/search/x", "GET api/endpoints/{ep}/messages/search/{keyword}"), + ]; + + [Test] + [TestCaseSource(nameof(MessagesViewPaths))] + public async Task Operator_receives_200_for_messages_view_path((string path, string description) testCase) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, HttpMethod.Get, testCase.path, token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + $"{testCase.description} should return 200 for sc-operator"); + } + + [Test] + [TestCaseSource(nameof(MessagesViewPaths))] + public async Task User_without_permission_receives_403_for_messages_view_path((string path, string description) testCase) + { + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + var token = mockOidcServer.GenerateTokenWithRealmRoles("dave", ["sc-no-perms"]); + response = await OpenIdConnectAssertions.SendRequestWithBearerToken( + HttpClient, HttpMethod.Get, testCase.path, token); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), + $"{testCase.description} should return 403 for user without messages:view"); + } + + [Test] + public async Task Messages_oidc_disabled_returns_200_without_token() + { + configuration?.Dispose(); + configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary) + .WithAuthenticationDisabled(); + + HttpResponseMessage response = null; + + _ = await Define() + .Done(async ctx => + { + response = await OpenIdConnectAssertions.SendRequestWithoutAuth(HttpClient, HttpMethod.Get, "/api/messages"); + return response != null; + }) + .Run(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + class Context : ScenarioContext; + } +} 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..7920103499 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs @@ -124,7 +124,9 @@ public async Task Should_accept_requests_with_valid_bearer_token() _ = await Define() .Done(async ctx => { - var validToken = mockOidcServer.GenerateToken(); + // Generate a token with sc-operator role so the user passes both authentication + // (valid JWT) and authorization (messages:view permission required by GET /api/errors). + var validToken = mockOidcServer.GenerateTokenWithRealmRoles("alice", ["sc-operator"]); response = await OpenIdConnectAssertions.SendRequestWithBearerToken( HttpClient, HttpMethod.Get, diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs b/src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs index 8862856c1e..f47458a353 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs @@ -26,6 +26,7 @@ abstract class AcceptanceTest : NServiceBusAcceptanceTest, IAcceptanceTestInfras public JsonSerializerOptions SerializerOptions => serviceControlRunnerBehavior.SerializerOptions; public Settings Settings => serviceControlRunnerBehavior.Settings; public Func HttpMessageHandlerFactory => serviceControlRunnerBehavior.HttpMessageHandlerFactory; + public IServiceProvider Services => serviceControlRunnerBehavior.Services; [SetUp] public void Setup() diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs index 3d0546edf6..a8778c4768 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs @@ -27,6 +27,7 @@ public ServiceControlComponentBehavior(ITransportIntegration transportToUse, Acc public Settings Settings => runner.Settings; public IDomainEvents DomainEvents => runner.DomainEvents; public Func HttpMessageHandlerFactory => runner.HttpMessageHandlerFactory; + public IServiceProvider Services => runner.Services; public async Task CreateRunner(RunDescriptor run) { diff --git a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 657a84244d..6df38480c5 100644 --- a/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -14,6 +14,7 @@ using Hosting.Https; using Infrastructure.DomainEvents; using Infrastructure.WebApi; + using Infrastructure.WebApi.Auth; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -45,6 +46,7 @@ public ServiceControlComponentRunner(ITransportIntegration transportToUse, Accep public JsonSerializerOptions SerializerOptions => Infrastructure.WebApi.SerializerOptions.Default; public Func HttpMessageHandlerFactory { get; private set; } public IDomainEvents DomainEvents { get; private set; } + public IServiceProvider Services => host?.Services; public Task Initialize(RunDescriptor run) => InitializeServiceControl(run.ScenarioContext); @@ -123,6 +125,8 @@ async Task InitializeServiceControl(ScenarioContext context) EnvironmentName = Environments.Development }); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlS2Authorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControl(settings, configuration); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl.Audit.AcceptanceTests/AcceptanceTest.cs b/src/ServiceControl.Audit.AcceptanceTests/AcceptanceTest.cs index 133e7e805a..b8b7d01890 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/AcceptanceTest.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/AcceptanceTest.cs @@ -22,6 +22,7 @@ abstract class AcceptanceTest : NServiceBusAcceptanceTest, IAcceptanceTestInfras { public HttpClient HttpClient => serviceControlRunnerBehavior.HttpClient; public JsonSerializerOptions SerializerOptions => serviceControlRunnerBehavior.SerializerOptions; + public IServiceProvider Services => serviceControlRunnerBehavior.Services; protected IServiceProvider ServiceProvider => serviceControlRunnerBehavior.ServiceProvider; [SetUp] diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs index b95cdd3d62..eff2782c67 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs @@ -22,6 +22,7 @@ class ServiceControlComponentBehavior( { public HttpClient HttpClient => runner.HttpClient; public JsonSerializerOptions SerializerOptions => runner.SerializerOptions; + public IServiceProvider Services => runner.Services; public IServiceProvider ServiceProvider => runner.ServiceProvider; public async Task CreateRunner(RunDescriptor run) diff --git a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index efcd99c0f6..5d79e0fd79 100644 --- a/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Audit.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -39,6 +39,7 @@ public class ServiceControlComponentRunner( public HttpClient HttpClient { get; private set; } public JsonSerializerOptions SerializerOptions => Infrastructure.WebApi.SerializerOptions.Default; public IServiceProvider ServiceProvider { get; private set; } + public IServiceProvider Services => ServiceProvider; public TestServer InstanceTestServer { get; private set; } public Task Initialize(RunDescriptor run) => InitializeServiceControl(run.ScenarioContext); diff --git a/src/ServiceControl.Hosting/Auth/AuthorizationHostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Auth/AuthorizationHostApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..843eb5a4ee --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/AuthorizationHostApplicationBuilderExtensions.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.Hosting.Auth; + +using System; +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Registers the ServiceControl RBAC authorization services. +/// Mirrors — +/// early-returns when OIDC is disabled so existing deployments are byte-for-byte unchanged. +/// +public static class AuthorizationHostApplicationBuilderExtensions +{ + /// + /// Registers the ServiceControl authorization services. + /// Does nothing when .Enabled is . + /// + public static void AddServiceControlAuthorization( + this IHostApplicationBuilder hostBuilder, + OpenIdConnectSettings oidcSettings) + { + if (!oidcSettings.Enabled) + { + return; + } + + // Note: the authenticated-user FallbackPolicy (spec §5.5) is intentionally NOT registered + // here — it is already wired up by AddServiceControlAuthentication via AddAuthorization() + // in HostApplicationBuilderExtensions. Duplicating it here would be redundant. + + // The policy is loaded once at startup (reloading is a later enhancement). + // We capture the load time so the descriptor endpoint can expose it as the 'version' field. + var policyFilePath = ResolveRbacPolicyPath(oidcSettings.RbacPolicyFile); + var policy = RbacPolicyLoader.LoadFromFile(policyFilePath); + + // Register as a singleton factory so Phase 1 can swap the policy at runtime. + hostBuilder.Services.AddSingleton>(() => policy); + + hostBuilder.Services.AddSingleton(sp => + new PermissionEvaluator(sp.GetRequiredService>())); + + hostBuilder.Services.AddSingleton(); + + // Ensure the claims transformation runs for every request so realm_access roles + // are flattened into individual 'role' claims before authorization is evaluated. + hostBuilder.Services.AddSingleton< + Microsoft.AspNetCore.Authentication.IClaimsTransformation, + RealmAccessClaimsTransformation>(); + } + + /// + /// Resolves the RBAC policy file path. If the configured path is not absolute, + /// resolve it relative to the directory containing the host assembly (i.e. the output folder). + /// + static string ResolveRbacPolicyPath(string configuredPath) + { + if (Path.IsPathRooted(configuredPath)) + { + return configuredPath; + } + + var baseDir = AppContext.BaseDirectory; + return Path.Combine(baseDir, configuredPath); + } +} diff --git a/src/ServiceControl.Hosting/Auth/RealmAccessClaimsTransformation.cs b/src/ServiceControl.Hosting/Auth/RealmAccessClaimsTransformation.cs new file mode 100644 index 0000000000..acb184e3f0 --- /dev/null +++ b/src/ServiceControl.Hosting/Auth/RealmAccessClaimsTransformation.cs @@ -0,0 +1,89 @@ +#nullable enable +namespace ServiceControl.Hosting.Auth; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; + +/// +/// An that flattens Keycloak's nested +/// realm_access.roles JSON claim into individual role claims, +/// making them available to policy matching and . +/// +/// Keycloak sets MapInboundClaims = false, so the realm_access +/// claim arrives as a raw JSON string. This transformation unpacks it. +/// The transformation is idempotent — roles already present as role claims +/// are not added again. +/// +/// +public class RealmAccessClaimsTransformation : IClaimsTransformation +{ + const string RealmAccessClaimType = "realm_access"; + const string RoleClaimType = "role"; + + public Task TransformAsync(ClaimsPrincipal principal) + { + var realmAccessClaim = principal.FindFirst(RealmAccessClaimType); + if (realmAccessClaim == null) + { + return Task.FromResult(principal); + } + + var roles = ParseRoles(realmAccessClaim.Value); + if (roles == null || roles.Count == 0) + { + return Task.FromResult(principal); + } + + // Collect existing role claims to avoid duplicates (idempotent) + var existingRoles = new HashSet(principal.FindAll(RoleClaimType) + .Select(c => c.Value), StringComparer.Ordinal); + + var claimsToAdd = roles + .Where(r => !existingRoles.Contains(r)) + .Select(r => new Claim(RoleClaimType, r)) + .ToList(); + + if (claimsToAdd.Count == 0) + { + return Task.FromResult(principal); + } + + // Clone the identity and add the new role claims + var identity = new ClaimsIdentity(principal.Identity); + identity.AddClaims(claimsToAdd); + + return Task.FromResult(new ClaimsPrincipal(identity)); + } + + static List? ParseRoles(string realmAccessJson) + { + try + { + using var doc = JsonDocument.Parse(realmAccessJson); + if (doc.RootElement.TryGetProperty("roles", out var rolesElement) && + rolesElement.ValueKind == JsonValueKind.Array) + { + var roles = new List(); + foreach (var role in rolesElement.EnumerateArray()) + { + var value = role.GetString(); + if (value != null) + { + roles.Add(value); + } + } + return roles; + } + } + catch (JsonException) + { + // Malformed realm_access claim — skip transformation + } + return null; + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/AuthorizationAuditLogTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/AuthorizationAuditLogTests.cs new file mode 100644 index 0000000000..279b31adca --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/AuthorizationAuditLogTests.cs @@ -0,0 +1,73 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ServiceControl.AcceptanceTesting.Auth; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class AuthorizationAuditLogTests +{ + [Test] + public void Decision_allow_emits_one_log_entry_on_the_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice", "messages:retry", "acme.sales", allowed: true, reason: "role:sc-operator matched"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].Message, Does.Contain("alice")); + Assert.That(entries[0].Message, Does.Contain("messages:retry")); + Assert.That(entries[0].Message, Does.Contain("allow")); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information)); + } + + [Test] + public void Decision_deny_emits_one_log_entry_on_the_audit_category() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("bob", "messages:retry", null, allowed: false, reason: "no matching role"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].Message, Does.Contain("bob")); + Assert.That(entries[0].Message, Does.Contain("messages:retry")); + Assert.That(entries[0].Message, Does.Contain("deny")); + Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information)); + } + + [Test] + public void Decision_does_not_appear_in_other_categories() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("carol", "endpoints:view", null, allowed: true, reason: "role:sc-viewer matched"); + + var otherEntries = provider.EntriesFor("ServiceControl.SomeOtherCategory"); + Assert.That(otherEntries, Is.Empty); + } + + [Test] + public void Multiple_decisions_accumulate_in_order() + { + var provider = new RecordingLoggerProvider(); + var factory = LoggerFactory.Create(b => b.AddProvider(provider)); + var auditLog = new AuthorizationAuditLog(factory); + + auditLog.Decision("alice", "messages:view", null, allowed: true, "role matched"); + auditLog.Decision("alice", "messages:retry", "acme.finance", allowed: false, "out of scope"); + + var entries = provider.EntriesFor("ServiceControl.Audit"); + Assert.That(entries, Has.Count.EqualTo(2)); + Assert.That(entries[0].Message, Does.Contain("allow")); + Assert.That(entries[1].Message, Does.Contain("deny")); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/FilterByQueueScopeTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/FilterByQueueScopeTests.cs new file mode 100644 index 0000000000..5d40bcd08e --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/FilterByQueueScopeTests.cs @@ -0,0 +1,104 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Unit tests for the queue-scope filtering logic documented in FilterByQueueScope. +/// These tests use ResourceScope.Permits (the in-process evaluator) to verify +/// the logical behaviour of scope patterns, since RavenDB's IAsyncDocumentQuery +/// is not easily mockable. The corresponding correctness of the RavenDB query +/// translation is verified by integration/acceptance tests. +/// +/// Bug coverage: +/// (a) D3(a): Deny prefix patterns (e.g. 'Finance.*') must exclude all queues starting +/// with 'Finance.' — not just the literal string 'Finance'. +/// (b) D3(b): Allow and deny patterns must be matched case-insensitively against the +/// lower-cased queue address stored in the index. +/// +[TestFixture] +public class FilterByQueueScopeTests +{ + [Test] + public void Deny_prefix_pattern_excludes_matching_queue() + { + // D3(a): "Sales.secret.*" should deny "sales.secret.payroll" + var scope = new ResourceScope( + allow: ["*"], + deny: ["Sales.secret.*"]); + + Assert.That(scope.Permits("sales.secret.payroll"), Is.False, + "Deny prefix 'Sales.secret.*' must exclude 'sales.secret.payroll'"); + } + + [Test] + public void Deny_prefix_pattern_does_not_exclude_non_matching_queue() + { + // "Sales.secret.*" must NOT deny "sales.public.orders" + var scope = new ResourceScope( + allow: ["*"], + deny: ["Sales.secret.*"]); + + Assert.That(scope.Permits("sales.public.orders"), Is.True, + "Deny prefix 'Sales.secret.*' must NOT exclude 'sales.public.orders'"); + } + + [Test] + public void Deny_exact_pattern_denies_only_exact_match() + { + // D3(a): exact deny "Finance" must NOT deny "finance.payroll". + // Queue addresses are stored in lowercase in the index; the pattern is also + // lowercased before comparison, so "Finance" → "finance". + var scope = new ResourceScope( + allow: ["*"], + deny: ["Finance"]); + + Assert.That(scope.Permits("finance.payroll"), Is.True, + "Exact deny 'Finance' (→ 'finance') must not deny 'finance.payroll'"); + Assert.That(scope.Permits("finance"), Is.False, + "Exact deny 'Finance' (→ 'finance') must deny exact lowercase 'finance'"); + } + + [Test] + public void Allow_prefix_pattern_lowercased_matches_stored_lower_case_queue() + { + // D3(b): FilterByQueueScope lowercases patterns before calling WhereStartsWith, + // so "Sales.*" → prefix "sales." which matches stored "sales.orders". + // We simulate this by pre-lowercasing the pattern, as the extension method does. + var scope = new ResourceScope( + allow: ["sales.*"], // pre-lowercased, as the query method applies + deny: []); + + Assert.That(scope.Permits("sales.orders"), Is.True, + "Lowercased allow 'sales.*' must match stored lowercase address 'sales.orders'"); + Assert.That(scope.Permits("finance.ap"), Is.False, + "Lowercased allow 'sales.*' must not match 'finance.ap'"); + } + + [Test] + public void Mixed_case_allow_pattern_after_lowercasing_matches_stored_queue() + { + // D3(b): mixed-case allow "Sales.*" must match lowercase "sales.orders" + // The extension method calls pattern.ToLowerInvariant() → "sales.*" → prefix "sales." + var mixedCasePattern = "Sales.*"; + var lower = mixedCasePattern.ToLowerInvariant(); // "sales.*" + var scope = new ResourceScope(allow: [lower], deny: []); + + Assert.That(scope.Permits("sales.orders"), Is.True, + "Pattern 'Sales.*' lowercased to 'sales.*' must match 'sales.orders'"); + } + + [Test] + public void Deny_prefix_pattern_wins_over_allow_wildcard() + { + // Full scenario: wildcard allow but Finance.* deny + var scope = new ResourceScope( + allow: ["*"], + deny: ["Finance.*"]); + + Assert.That(scope.Permits("finance.accounts"), Is.False, + "Finance.* deny wins over wildcard allow for 'finance.accounts'"); + Assert.That(scope.Permits("sales.orders"), Is.True, + "Finance.* deny must not affect 'sales.orders'"); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionEvaluatorTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionEvaluatorTests.cs new file mode 100644 index 0000000000..99ced06621 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionEvaluatorTests.cs @@ -0,0 +1,237 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using System.Linq; +using System.Security.Claims; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class PermissionEvaluatorTests +{ + const string OperatorPolicyYaml = """ + schemaVersion: 1 + roles: + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + - "messages:view" + - "messages:retry" + - "messages:archive" + """; + + const string ScopedPolicyYaml = """ + schemaVersion: 1 + roles: + scoped-role: + bindings: [ "group:/devops/sales" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["acme.sales.*"], deny: [] } + """; + + const string AdminPolicyYaml = """ + schemaVersion: 1 + roles: + sc-admin: + bindings: [ "role:sc-admin" ] + permissions: [ "*" ] + """; + + [Test] + public void Operator_has_retry_but_not_admin_only_permission() + { + var policy = RbacPolicyLoader.Parse(OperatorPolicyYaml); + var evaluator = new PermissionEvaluator(() => policy); + var user = PrincipalWithRoles("sc-operator"); + + Assert.That(evaluator.HasPermission(user, "messages:retry"), Is.True); + Assert.That(evaluator.HasPermission(user, "configuration:manage"), Is.False); + } + + [Test] + public void IsInScope_applies_allow_and_deny_patterns() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(ScopedPolicyYaml)); + var user = PrincipalWithGroups("/devops/sales"); + + Assert.That(evaluator.IsInScope(user, "messages:retry", "acme.sales.orders"), Is.True); + Assert.That(evaluator.IsInScope(user, "messages:retry", "acme.finance.ap"), Is.False); + } + + [Test] + public void Wildcard_permission_grants_access_to_all_permissions() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(AdminPolicyYaml)); + var user = PrincipalWithRoles("sc-admin"); + + Assert.That(evaluator.HasPermission(user, "messages:retry"), Is.True); + Assert.That(evaluator.HasPermission(user, "configuration:manage"), Is.True); + Assert.That(evaluator.HasPermission(user, "anything:else"), Is.True); + } + + [Test] + public void Wildcard_permission_is_in_scope_for_any_resource() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(AdminPolicyYaml)); + var user = PrincipalWithRoles("sc-admin"); + + Assert.That(evaluator.IsInScope(user, "messages:retry", "any.queue"), Is.True); + Assert.That(evaluator.IsInScope(user, "anything:else", "another.queue"), Is.True); + } + + [Test] + public void Unrestricted_grant_is_in_scope_for_any_resource() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(OperatorPolicyYaml)); + var user = PrincipalWithRoles("sc-operator"); + + // messages:retry is granted without a scope, so any resource is in scope + Assert.That(evaluator.IsInScope(user, "messages:retry", "any.queue"), Is.True); + Assert.That(evaluator.IsInScope(user, "messages:retry", "another.queue"), Is.True); + } + + [Test] + public void User_without_matching_role_has_no_permissions() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(OperatorPolicyYaml)); + var user = PrincipalWithRoles("sc-viewer"); + + Assert.That(evaluator.HasPermission(user, "messages:view"), Is.False); + } + + [Test] + public void Resolve_returns_effective_permissions_for_user() + { + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(OperatorPolicyYaml)); + var user = PrincipalWithRoles("sc-operator"); + + var effective = evaluator.Resolve(user); + + var permissions = effective.Grants.Select(g => g.Permission).ToArray(); + Assert.That(permissions, Does.Contain("messages:retry")); + Assert.That(permissions, Does.Contain("messages:view")); + Assert.That(permissions, Does.Contain("messages:archive")); + } + + [Test] + public void IsInScope_grant_deny_in_one_role_does_not_block_allow_in_another_role() + { + // role-a covers messages:retry but explicitly denies the target resource + // role-b covers messages:retry and allows the same resource + // A user in both roles must be permitted — a deny in one grant must not + // leak across to a separate grant from a different role. + const string yaml = """ + schemaVersion: 1 + roles: + role-a: + bindings: [ "role:role-a" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["acme.*"], deny: ["acme.finance.*"] } + role-b: + bindings: [ "role:role-b" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["acme.finance.*"], deny: [] } + """; + + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(yaml)); + + // user is in both roles + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("role", "role-a")); + identity.AddClaim(new Claim("role", "role-b")); + var user = new ClaimsPrincipal(identity); + + // role-a denies acme.finance.ap but role-b allows it — overall: permitted + Assert.That(evaluator.IsInScope(user, "messages:retry", "acme.finance.ap"), Is.True, + "Grant B's allow should win independently of grant A's deny"); + + // role-a allows acme.sales.orders; role-b also allows it — still permitted + Assert.That(evaluator.IsInScope(user, "messages:retry", "acme.sales.orders"), Is.True); + } + + [Test] + public void Resolve_deduplicates_identical_grants_from_multiple_roles() + { + // Both role-a and role-b grant messages:view with no scope. + // A user in both roles should see exactly one messages:view entry in the descriptor. + const string yaml = """ + schemaVersion: 1 + roles: + role-a: + bindings: [ "role:role-a" ] + permissions: [ "messages:view" ] + role-b: + bindings: [ "role:role-b" ] + permissions: [ "messages:view" ] + """; + + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(yaml)); + + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("role", "role-a")); + identity.AddClaim(new Claim("role", "role-b")); + var user = new ClaimsPrincipal(identity); + + var effective = evaluator.Resolve(user); + + var viewGrants = effective.Grants.Where(g => g.Permission == "messages:view").ToArray(); + Assert.That(viewGrants, Has.Length.EqualTo(1), + "Two roles granting the same permission+scope must yield exactly one entry in the descriptor"); + } + + [Test] + public void Resolve_preserves_distinct_scopes_for_same_permission() + { + // role-a grants messages:retry scoped to acme.sales.*, role-b grants it scoped to acme.finance.*. + // Both entries must appear (OR semantics: user can retry in either scope). + const string yaml = """ + schemaVersion: 1 + roles: + role-a: + bindings: [ "role:role-a" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["acme.sales.*"], deny: [] } + role-b: + bindings: [ "role:role-b" ] + permissions: + - permission: "messages:retry" + scope: { allow: ["acme.finance.*"], deny: [] } + """; + + var evaluator = new PermissionEvaluator(() => RbacPolicyLoader.Parse(yaml)); + + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("role", "role-a")); + identity.AddClaim(new Claim("role", "role-b")); + var user = new ClaimsPrincipal(identity); + + var effective = evaluator.Resolve(user); + + var retryGrants = effective.Grants.Where(g => g.Permission == "messages:retry").ToArray(); + Assert.That(retryGrants, Has.Length.EqualTo(2), + "Two roles granting the same permission with different scopes must yield two entries (OR semantics)"); + } + + static ClaimsPrincipal PrincipalWithRoles(params string[] roles) + { + var identity = new ClaimsIdentity("Bearer"); + foreach (var role in roles) + { + identity.AddClaim(new Claim("role", role)); + } + return new ClaimsPrincipal(identity); + } + + static ClaimsPrincipal PrincipalWithGroups(params string[] groups) + { + var identity = new ClaimsIdentity("Bearer"); + foreach (var group in groups) + { + identity.AddClaim(new Claim("group", group)); + } + return new ClaimsPrincipal(identity); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionsTests.cs new file mode 100644 index 0000000000..97eb5db885 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/PermissionsTests.cs @@ -0,0 +1,31 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class PermissionsTests +{ + [Test] + public void All_contains_messages_retry() + => Assert.That(Permissions.All, Does.Contain(Permissions.MessagesRetry)); + + [Test] + public void All_entries_are_unique() + => Assert.That(Permissions.All.Distinct().Count(), Is.EqualTo(Permissions.All.Count)); + + [Test] + public void All_entries_match_resource_action_pattern_or_are_wildcard() + { + var pattern = new Regex(@"^[a-z]+:[a-z-]+$"); + foreach (var permission in Permissions.All) + { + Assert.That( + permission == "*" || pattern.IsMatch(permission), + Is.True, + $"Permission '{permission}' does not match 'resource:action' pattern"); + } + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyLoaderTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyLoaderTests.cs new file mode 100644 index 0000000000..26087f6070 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyLoaderTests.cs @@ -0,0 +1,46 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class RbacPolicyLoaderTests +{ + [Test] + public void Parses_roles_permissions_and_scope() + { + const string yaml = """ + schemaVersion: 1 + roles: + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + - "messages:view" + - permission: "messages:retry" + scope: { allow: ["acme.sales.*"], deny: ["acme.sales.secret.*"] } + """; + var policy = RbacPolicyLoader.Parse(yaml); + + var ops = policy.Roles["sc-operator"]; + Assert.That(ops.Bindings, Does.Contain("role:sc-operator")); + Assert.That(ops.Permissions[0].Permission, Is.EqualTo("messages:view")); + Assert.That(ops.Permissions[0].Scope, Is.Null); + Assert.That(ops.Permissions[1].Scope!.Allow, Does.Contain("acme.sales.*")); + } + + [Test] + public void Invalid_yaml_throws_with_a_clear_message() + => Assert.That(() => RbacPolicyLoader.Parse("not: : valid"), + Throws.Exception.With.Message.Contains("rbac")); + + [Test] + public void LoadFromFile_non_existent_path_throws_RbacPolicyException_containing_path() + { + const string missingPath = "/tmp/_claude/does-not-exist/rbac-policy.yaml"; + + Assert.That( + () => RbacPolicyLoader.LoadFromFile(missingPath), + Throws.TypeOf() + .With.Message.Contains(missingPath)); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyTests.cs new file mode 100644 index 0000000000..ed2820dfbf --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/RbacPolicyTests.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using System.Collections.Generic; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class RbacPolicyTests +{ + [Test] + public void Role_exposes_its_permission_grants() + { + var role = new RbacRole("sc-operator", + Bindings: ["role:sc-operator"], + Permissions: [new PermissionGrant("messages:retry", Scope: null)]); + var policy = new RbacPolicy(SchemaVersion: 1, Roles: new Dictionary { ["sc-operator"] = role }); + + Assert.That(policy.Roles["sc-operator"].Permissions[0].Permission, Is.EqualTo("messages:retry")); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/ResourceScopeTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/ResourceScopeTests.cs new file mode 100644 index 0000000000..97894b7fbe --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/Rbac/ResourceScopeTests.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Infrastructure.Tests.Auth.Rbac; + +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth.Rbac; + +[TestFixture] +public class ResourceScopeTests +{ + [TestCase("acme.sales", new[] { "acme.sales.*" }, new string[0], ExpectedResult = false)] + [TestCase("acme.sales.orders", new[] { "acme.sales.*" }, new string[0], ExpectedResult = true)] + [TestCase("acme.sales.orders", new[] { "*" }, new[] { "acme.sales.*" }, ExpectedResult = false)] + [TestCase("acme.logistics", new[] { "*" }, new[] { "acme.secret.*" }, ExpectedResult = true)] + [TestCase("acme.sales", new[] { "acme.sales" }, new string[0], ExpectedResult = true)] + public bool Permits(string resource, string[] allow, string[] deny) + => new ResourceScope(allow, deny).Permits(resource); +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RbacSettingsTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RbacSettingsTests.cs new file mode 100644 index 0000000000..e7081d0f1e --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RbacSettingsTests.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Infrastructure.Tests.Auth; + +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; + +[TestFixture] +public class RbacSettingsTests +{ + [Test] + public void RbacPolicyFile_defaults_to_rbac_yaml() + { + var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), + validateConfiguration: false); + Assert.That(settings.RbacPolicyFile, Is.EqualTo("rbac.yaml")); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RealmAccessClaimsTransformationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RealmAccessClaimsTransformationTests.cs new file mode 100644 index 0000000000..9d52935e57 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RealmAccessClaimsTransformationTests.cs @@ -0,0 +1,52 @@ +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.IdentityModel.JsonWebTokens; +using NUnit.Framework; +using ServiceControl.Hosting.Auth; + +[TestFixture] +public class RealmAccessClaimsTransformationTests +{ + [Test] + public async Task Flattens_realm_access_roles_into_role_claims() + { + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("realm_access", """{"roles":["sc-admin","sc-operator"]}""", JsonClaimValueTypes.Json)); + var principal = new ClaimsPrincipal(identity); + + var result = await new RealmAccessClaimsTransformation().TransformAsync(principal); + + var roles = result.FindAll("role").Select(c => c.Value).ToArray(); + Assert.That(roles, Is.EquivalentTo(new[] { "sc-admin", "sc-operator" })); + } + + [Test] + public async Task Does_not_duplicate_role_claims_on_repeated_transformation() + { + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("realm_access", """{"roles":["sc-admin"]}""", JsonClaimValueTypes.Json)); + var principal = new ClaimsPrincipal(identity); + + var transformation = new RealmAccessClaimsTransformation(); + var result = await transformation.TransformAsync(principal); + result = await transformation.TransformAsync(result); + + var roles = result.FindAll("role").Select(c => c.Value).ToArray(); + Assert.That(roles, Has.Length.EqualTo(1)); + } + + [Test] + public async Task Principal_without_realm_access_is_returned_unchanged() + { + var identity = new ClaimsIdentity("Bearer"); + identity.AddClaim(new Claim("sub", "user123")); + var principal = new ClaimsPrincipal(identity); + + var result = await new RealmAccessClaimsTransformation().TransformAsync(principal); + + Assert.That(result.FindAll("role"), Is.Empty); + } +} diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 4cddac2b26..c72dd8bae3 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -5,6 +5,8 @@ + + diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/AuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/AuthorizationAuditLog.cs new file mode 100644 index 0000000000..275fc58045 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/AuthorizationAuditLog.cs @@ -0,0 +1,40 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using Microsoft.Extensions.Logging; + +/// +/// Logs every authorization decision as a structured log entry on category +/// ServiceControl.Audit. The category is intentionally stable so +/// sinks can filter on it without coupling to the concrete type name. +/// +public sealed partial class AuthorizationAuditLog : IAuthorizationAuditLog +{ + const string AuditCategory = "ServiceControl.Audit"; + + readonly ILogger logger; + + public AuthorizationAuditLog(ILoggerFactory loggerFactory) + { + logger = loggerFactory.CreateLogger(AuditCategory); + } + + /// + public void Decision(string subject, string permission, string? resource, bool allowed, string reason) + { + LogDecision(logger, subject, permission, resource, allowed ? "allow" : "deny", reason); + } + + // Source-generated structured log method — zero allocation on the hot path. + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "Authorization {Outcome}: subject={Subject} permission={Permission} resource={Resource} reason={Reason}")] + static partial void LogDecision( + ILogger logger, + string subject, + string permission, + string? resource, + string outcome, + string reason); +} diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/EffectivePermissions.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/EffectivePermissions.cs new file mode 100644 index 0000000000..6412fa7a24 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/EffectivePermissions.cs @@ -0,0 +1,28 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System.Collections.Generic; + +/// +/// The resolved set of permissions for a user, derived from their claims and the RBAC policy. +/// +/// OR semantics: when multiple entries share the same +/// permission name but carry different scopes, a consumer should treat the user as having +/// that permission for a given resource if any entry's scope permits the resource. +/// Identical grants (same permission AND same scope) are deduplicated by +/// so consumers never see duplicates. +/// +/// +public sealed class EffectivePermissions(IReadOnlyList grants) +{ + /// + /// The deduplicated list of effective permission grants. Each entry has a permission name + /// and an optional scope. A null scope means the permission is unrestricted (all resources allowed). + /// + public IReadOnlyList Grants { get; } = grants; +} + +/// +/// A single effective permission grant: the permission name and the resource scope it applies to. +/// +public sealed record EffectiveGrant(string Permission, ResourceScope? Scope); diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/IAuthorizationAuditLog.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/IAuthorizationAuditLog.cs new file mode 100644 index 0000000000..cac75b65f1 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/IAuthorizationAuditLog.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Records every authorization allow/deny decision. +/// Implementations write structured log entries under the category +/// ServiceControl.Audit so they can be collected by any +/// ILogger-compatible sink (Seq, OTLP, in-memory test double, …). +/// +public interface IAuthorizationAuditLog +{ + /// + /// Records a single authorization decision. + /// + /// The identity of the principal (e.g. the sub claim). + /// The permission that was evaluated (e.g. messages:retry). + /// The specific resource checked, or for verb-level checks. + /// if the decision was allow; for deny. + /// A human-readable explanation (e.g. which policy rule matched, or why it didn't). + void Decision(string subject, string permission, string? resource, bool allowed, string reason); +} diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/IPermissionEvaluator.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/IPermissionEvaluator.cs new file mode 100644 index 0000000000..6797a59cc3 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/IPermissionEvaluator.cs @@ -0,0 +1,43 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System.Security.Claims; + +/// +/// Evaluates a user's permissions against the loaded RBAC policy. +/// +public interface IPermissionEvaluator +{ + /// + /// Returns true if the user has the specified permission (regardless of resource scope). + /// A wildcard (*) grant satisfies any permission. + /// + bool HasPermission(ClaimsPrincipal user, string permission); + + /// + /// Returns true if the user has the specified permission for the given resource. + /// A grant without a scope (null Scope) is in scope for all resources. + /// A wildcard (*) grant is in scope for all permissions and all resources. + /// + bool IsInScope(ClaimsPrincipal user, string permission, string resource); + + /// + /// Returns true if the user holds at least one unrestricted (null-scope) grant for the + /// given permission. An unrestricted grant means the user can access all resources. + /// A wildcard (*) permission grant satisfies any named permission. + /// + bool HasUnrestrictedGrant(ClaimsPrincipal user, string permission); + + /// + /// Resolves the full set of effective permissions for the user based on their claims + /// and the current RBAC policy. + /// + EffectivePermissions Resolve(ClaimsPrincipal user); + + /// + /// Returns the resolved queue scope for the user's grants for the given permission, + /// or if the user has an unrestricted grant. + /// Used by the data layer to push scope filtering into the query before paging. + /// + ResourceScope? ResolveQueueScope(ClaimsPrincipal user, string permission); +} diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/KnownUnenforcedPermissions.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/KnownUnenforcedPermissions.cs new file mode 100644 index 0000000000..f7963f16ee --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/KnownUnenforcedPermissions.cs @@ -0,0 +1,79 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System.Collections.Generic; + +/// +/// The set of permission constants that are declared in but are not +/// yet enforced by any [Authorize(Policy = X)] attribute on a controller action. +/// +/// This set serves as the "known-unenforced" allowlist for the catalogue cross-check test +/// (Catalogue_completeness_tests). When a permission is wired up with +/// [Authorize(Policy = X)], remove it from this set; when a new unenforced constant is added +/// to , add it here until enforcement is implemented. +/// +/// +/// On tf3651-authz-s2: all messages & recoverability permissions are enforced. +/// +/// +public static class KnownUnenforcedPermissions +{ + /// + /// Every permission constant that is declared but not yet enforced by an + /// [Authorize(Policy = X)] attribute on a controller action method. + /// + public static readonly IReadOnlySet Set = new HashSet + { + // Messages area — fully enforced on tf3651-authz-s2; all removed from this set. + // Recoverability groups area — fully enforced on tf3651-authz-s2; all removed. + + // Endpoints area — enforcement planned in a later phase + Permissions.EndpointsView, + Permissions.EndpointsManage, + Permissions.EndpointsDelete, + + // Heartbeats area — enforcement planned in a later phase + Permissions.HeartbeatsView, + + // Custom checks area — enforcement planned in a later phase + Permissions.CustomChecksView, + Permissions.CustomChecksDelete, + + // Sagas area — enforcement planned in a later phase + Permissions.SagasView, + + // Event log area — enforcement planned in a later phase + Permissions.EventLogView, + + // Licensing area — enforcement planned in a later phase + Permissions.LicensingView, + Permissions.LicensingManage, + + // Notifications area — enforcement planned in a later phase + Permissions.NotificationsView, + Permissions.NotificationsManage, + Permissions.NotificationsTest, + + // Retry redirects area — enforcement planned in a later phase + Permissions.RedirectsView, + Permissions.RedirectsManage, + + // Queue addresses area — enforcement planned in a later phase + Permissions.QueuesView, + Permissions.QueuesDelete, + + // Throughput area — enforcement planned in a later phase + Permissions.ThroughputView, + Permissions.ThroughputManage, + + // Platform connections area — enforcement planned in a later phase + Permissions.ConnectionsView, + Permissions.ConnectionsManage, + + // Monitoring area — enforcement planned in a later phase + Permissions.MonitoringView, + + // Audit area — enforcement planned in a later phase + Permissions.AuditView, + }; +} diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/PermissionEvaluator.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/PermissionEvaluator.cs new file mode 100644 index 0000000000..4bbd98bd94 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/PermissionEvaluator.cs @@ -0,0 +1,241 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +/// +/// Evaluates a user's permissions against an loaded via a factory delegate. +/// The factory is called on each evaluation, allowing the policy to be reloaded at runtime. +/// +/// Binding resolution rules: +/// - role:X matches a role claim with value X +/// - group:/path matches a group claim with value /path +/// +/// +public sealed class PermissionEvaluator(Func policyFactory) : IPermissionEvaluator +{ + public bool HasPermission(ClaimsPrincipal user, string permission) + { + var policy = policyFactory(); + foreach (var role in MatchingRoles(user, policy)) + { + foreach (var grant in role.Permissions) + { + if (GrantMatchesPermission(grant, permission)) + { + return true; + } + } + } + return false; + } + + public bool IsInScope(ClaimsPrincipal user, string permission, string resource) + { + var policy = policyFactory(); + foreach (var role in MatchingRoles(user, policy)) + { + foreach (var grant in role.Permissions) + { + if (!GrantMatchesPermission(grant, permission)) + { + continue; + } + + // No scope (including wildcard "*") means the permission applies to all resources + if (grant.Scope == null) + { + return true; + } + + var scope = new ResourceScope(grant.Scope.Allow, grant.Scope.Deny); + if (scope.Permits(resource)) + { + return true; + } + } + } + return false; + } + + public bool HasUnrestrictedGrant(ClaimsPrincipal user, string permission) + { + var policy = policyFactory(); + foreach (var role in MatchingRoles(user, policy)) + { + foreach (var grant in role.Permissions) + { + if (GrantMatchesPermission(grant, permission) && grant.Scope == null) + { + return true; + } + } + } + return false; + } + + /// + /// Returns the effective queue scope for the user for the given permission, + /// or if the user has an unrestricted grant. + /// When multiple scoped grants exist, their allow/deny lists are merged (OR semantics on allow). + /// + public ResourceScope? ResolveQueueScope(ClaimsPrincipal user, string permission) + { + // If the user has any unrestricted grant, return null (no filter needed). + if (HasUnrestrictedGrant(user, permission)) + { + return null; + } + + var policy = policyFactory(); + var mergedAllow = new List(); + var mergedDeny = new List(); + + foreach (var role in MatchingRoles(user, policy)) + { + foreach (var grant in role.Permissions) + { + if (!GrantMatchesPermission(grant, permission)) + { + continue; + } + + if (grant.Scope != null) + { + foreach (var pattern in grant.Scope.Allow) + { + if (!mergedAllow.Contains(pattern)) + { + mergedAllow.Add(pattern); + } + } + + foreach (var pattern in grant.Scope.Deny) + { + if (!mergedDeny.Contains(pattern)) + { + mergedDeny.Add(pattern); + } + } + } + } + } + + // No scoped grants → deny all (user has no applicable grants) + if (mergedAllow.Count == 0) + { + return new ResourceScope([], []); + } + + return new ResourceScope(mergedAllow, mergedDeny); + } + + /// + /// Resolves the full set of effective permissions for the user based on their claims + /// and the current RBAC policy. Identical grants (same permission AND same scope) from + /// multiple matching roles are deduplicated — a user in two roles that both grant + /// messages:view with no scope yields exactly one entry. + /// + /// OR semantics: multiple entries for the same permission with different scopes + /// are all preserved. ServicePulse evaluates can(permission, resource) as + /// true if any entry for that permission permits the resource. + /// All downstream branches consuming this descriptor must apply the same OR semantics. + /// + /// + public EffectivePermissions Resolve(ClaimsPrincipal user) + { + var policy = policyFactory(); + var seen = new HashSet(); + var grants = new List(); + + foreach (var role in MatchingRoles(user, policy)) + { + foreach (var grant in role.Permissions) + { + ResourceScope? scope = grant.Scope != null + ? new ResourceScope(grant.Scope.Allow, grant.Scope.Deny) + : null; + + // Build a canonical key to detect identical grants (same permission + same scope). + // Different scopes for the same permission are intentionally preserved + // (OR semantics: any matching entry grants access). + var key = GrantKey(grant.Permission, scope); + if (!seen.Add(key)) + { + continue; + } + + grants.Add(new EffectiveGrant(grant.Permission, scope)); + } + } + + return new EffectivePermissions(grants); + } + + /// + /// Produces a stable string key representing a (permission, scope) pair for deduplication. + /// Scope patterns are sorted so that identical pattern sets in different order compare equal. + /// + static string GrantKey(string permission, ResourceScope? scope) + { + if (scope == null) + { + return permission; + } + + var allow = string.Join(',', scope.Allow.Order(StringComparer.Ordinal)); + var deny = string.Join(',', scope.Deny.Order(StringComparer.Ordinal)); + return $"{permission}|allow:{allow}|deny:{deny}"; + } + + /// + /// Yields all roles from the policy whose bindings match at least one of the user's claims. + /// + static IEnumerable MatchingRoles(ClaimsPrincipal user, RbacPolicy policy) + { + foreach (var role in policy.Roles.Values) + { + if (RoleBindingsMatch(user, role.Bindings)) + { + yield return role; + } + } + } + + /// + /// Returns true if the user has at least one claim matching any binding in the list. + /// Binding format: + /// - role:X → claim type role, value X + /// - group:/path → claim type group, value /path + /// + static bool RoleBindingsMatch(ClaimsPrincipal user, IReadOnlyList bindings) + { + foreach (var binding in bindings) + { + var colonIndex = binding.IndexOf(':'); + if (colonIndex < 0) + { + continue; + } + + var claimType = binding[..colonIndex]; + var claimValue = binding[(colonIndex + 1)..]; + + if (user.HasClaim(claimType, claimValue)) + { + return true; + } + } + return false; + } + + /// + /// Returns true if the grant covers the requested permission. + /// A * permission in the grant matches everything. + /// + static bool GrantMatchesPermission(PermissionGrant grant, string permission) => + grant.Permission == "*" || string.Equals(grant.Permission, permission, StringComparison.Ordinal); +} diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/Permissions.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/Permissions.cs new file mode 100644 index 0000000000..0dec32b112 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/Permissions.cs @@ -0,0 +1,118 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System.Collections.Generic; +using System.Reflection; + +/// +/// Catalogue of all known permission constants in the format resource:action. +/// Phase 0 seeds the messages and recoverability set. Later phases extend it. +/// +/// 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 +{ + /// Messages area — viewing, retrying, archiving, and editing failed messages. + public const string MessagesView = "messages:view"; + /// + public const string MessagesRetry = "messages:retry"; + /// + public const string MessagesArchive = "messages:archive"; + /// + public const string MessagesUnarchive = "messages:unarchive"; + /// + public const string MessagesEdit = "messages:edit"; + + /// Recoverability groups area — viewing, retrying, archiving, and unarchiving failure groups. + public const string RecoverabilityGroupsView = "recoverabilitygroups:view"; + /// + public const string RecoverabilityGroupsRetry = "recoverabilitygroups:retry"; + /// + public const string RecoverabilityGroupsArchive = "recoverabilitygroups:archive"; + /// + public const string RecoverabilityGroupsUnarchive = "recoverabilitygroups:unarchive"; + + /// Endpoints area — viewing, managing, and deleting monitored endpoints. + public const string EndpointsView = "endpoints:view"; + /// + public const string EndpointsManage = "endpoints:manage"; + /// + public const string EndpointsDelete = "endpoints:delete"; + + /// Heartbeats area — viewing heartbeat status for endpoints. + public const string HeartbeatsView = "heartbeats:view"; + + /// Custom checks area — viewing and deleting custom check results. + public const string CustomChecksView = "customchecks:view"; + /// + public const string CustomChecksDelete = "customchecks:delete"; + + /// Sagas area — viewing saga audit data. + public const string SagasView = "sagas:view"; + + /// Event log area — viewing the event log. + public const string EventLogView = "eventlog:view"; + + /// Licensing area — viewing and managing license configuration. + public const string LicensingView = "licensing:view"; + /// + public const string LicensingManage = "licensing:manage"; + + /// Notifications area — viewing, managing, and testing notification settings. + public const string NotificationsView = "notifications:view"; + /// + public const string NotificationsManage = "notifications:manage"; + /// + public const string NotificationsTest = "notifications:test"; + + /// Retry redirects area — viewing and managing message redirect rules. + public const string RedirectsView = "redirects:view"; + /// + public const string RedirectsManage = "redirects:manage"; + + /// Queue addresses area — viewing and deleting queue address entries. + public const string QueuesView = "queues:view"; + /// + public const string QueuesDelete = "queues:delete"; + + /// Throughput area — viewing and managing throughput reports and settings. + public const string ThroughputView = "throughput:view"; + /// + public const string ThroughputManage = "throughput:manage"; + + /// Platform connections area — viewing and managing broker/platform connection settings. + public const string ConnectionsView = "connections:view"; + /// + public const string ConnectionsManage = "connections:manage"; + + /// Monitoring area — read-only access to the Monitoring instance (separate process). + public const string MonitoringView = "monitoring:view"; + + /// Audit area — read-only access to the Audit instance (separate process). + public const string AuditView = "audit:view"; + + /// + /// The complete set of known permissions, derived from all public const string + /// fields declared on this class. Used by tests to assert coverage and by the completeness check. + /// + 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/Rbac/RbacPolicy.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/RbacPolicy.cs new file mode 100644 index 0000000000..6c0fb843e1 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/RbacPolicy.cs @@ -0,0 +1,36 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System; +using System.Collections.Generic; + +/// +/// The top-level RBAC policy, loaded from rbac.yaml. +/// +public sealed record RbacPolicy(int SchemaVersion, IReadOnlyDictionary Roles) +{ + /// + /// When the policy was loaded from disk. Set by ; + /// defaults to if constructed directly (e.g. in tests). + /// Used as the version field in the GET /api/me/permissions response so + /// ServicePulse can detect policy staleness. + /// + public DateTimeOffset LoadedAt { get; init; } = DateTimeOffset.UtcNow; +} + +/// +/// A named role with IdP bindings and permission grants. +/// +public sealed record RbacRole(string Name, IReadOnlyList Bindings, IReadOnlyList Permissions); + +/// +/// A single permission grant, optionally scoped to a resource pattern set. +/// A null Scope means the permission applies to all resources (unrestricted). +/// +public sealed record PermissionGrant(string Permission, ResourceScopeSpec? Scope); + +/// +/// Allowlist and denylist patterns for resource-scoped permissions. +/// Deny wins if a resource matches both allow and deny patterns. +/// +public sealed record ResourceScopeSpec(IReadOnlyList Allow, IReadOnlyList Deny); diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/RbacPolicyLoader.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/RbacPolicyLoader.cs new file mode 100644 index 0000000000..6f4db671ec --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/RbacPolicyLoader.cs @@ -0,0 +1,179 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System; +using System.Collections.Generic; +using System.IO; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +/// +/// Loads and parses an RBAC policy from YAML. The file format supports two forms for +/// permission entries: +/// - A bare string: "messages:view" +/// - An object with a scope: { permission: "messages:retry", scope: { allow: [...], deny: [...] } } +/// +public static class RbacPolicyLoader +{ + static readonly IDeserializer Deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new PermissionGrantConverter()) + .Build(); + + /// + /// Parses an RBAC policy from a YAML string. Throws + /// if the YAML is invalid or cannot be deserialized. + /// + public static RbacPolicy Parse(string yaml) + { + try + { + var dto = Deserializer.Deserialize(yaml); + return MapToPolicy(dto); + } + catch (Exception ex) when (ex is not RbacPolicyException) + { + throw new RbacPolicyException($"Failed to parse rbac policy: {ex.Message}", ex); + } + } + + /// + /// Loads and parses an RBAC policy from a YAML file at the given path. + /// + public static RbacPolicy LoadFromFile(string path) + { + try + { + var yaml = File.ReadAllText(path); + return Parse(yaml); + } + catch (RbacPolicyException) + { + throw; + } + catch (Exception ex) + { + throw new RbacPolicyException($"Failed to load rbac policy from '{path}': {ex.Message}", ex); + } + } + + static RbacPolicy MapToPolicy(RbacPolicyDto dto) + { + var roles = new Dictionary(StringComparer.Ordinal); + if (dto.Roles != null) + { + foreach (var (key, roleDto) in dto.Roles) + { + var bindings = (IReadOnlyList)(roleDto.Bindings ?? []); + var permissions = new List(); + if (roleDto.Permissions != null) + { + permissions.AddRange(roleDto.Permissions); + } + roles[key] = new RbacRole(key, bindings, permissions); + } + } + return new RbacPolicy(dto.SchemaVersion, roles) { LoadedAt = DateTimeOffset.UtcNow }; + } + + // DTO types used for deserialization only + class RbacPolicyDto + { + public int SchemaVersion { get; set; } + public Dictionary? Roles { get; set; } + } + + class RbacRoleDto + { + public List? Bindings { get; set; } + public List? Permissions { get; set; } + } + + /// + /// Handles the polymorphic permission entries: either a bare string or an object + /// with a "permission" key and optional "scope". + /// + class PermissionGrantConverter : IYamlTypeConverter + { + public bool Accepts(Type type) => type == typeof(PermissionGrant); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + // Bare string form: "messages:view" + if (parser.TryConsume(out var scalar)) + { + return new PermissionGrant(scalar.Value, Scope: null); + } + + // Object form: { permission: "messages:retry", scope: { allow: [...], deny: [...] } } + parser.Consume(); + + string? permission = null; + ResourceScopeSpec? scope = null; + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume().Value; + switch (key) + { + case "permission": + permission = parser.Consume().Value; + break; + case "scope": + scope = ReadScope(parser, rootDeserializer); + break; + default: + // Skip unknown keys + parser.SkipThisAndNestedEvents(); + break; + } + } + + if (permission == null) + { + throw new RbacPolicyException("rbac policy: permission entry missing 'permission' key"); + } + + return new PermissionGrant(permission, scope); + } + + static ResourceScopeSpec ReadScope(IParser parser, ObjectDeserializer rootDeserializer) + { + parser.Consume(); + + List? allow = null; + List? deny = null; + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume().Value; + switch (key) + { + case "allow": + allow = (List?)rootDeserializer(typeof(List)); + break; + case "deny": + deny = (List?)rootDeserializer(typeof(List)); + break; + default: + parser.SkipThisAndNestedEvents(); + break; + } + } + + return new ResourceScopeSpec(allow ?? [], deny ?? []); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + => throw new NotSupportedException("Writing RbacPolicy YAML is not supported."); + } +} + +/// +/// Thrown when the RBAC policy file cannot be loaded or parsed. +/// The message always contains "rbac" so tests can assert on it. +/// +public sealed class RbacPolicyException(string message, Exception? inner = null) + : Exception(message, inner); diff --git a/src/ServiceControl.Infrastructure/Auth/Rbac/ResourceScope.cs b/src/ServiceControl.Infrastructure/Auth/Rbac/ResourceScope.cs new file mode 100644 index 0000000000..6e341f1d32 --- /dev/null +++ b/src/ServiceControl.Infrastructure/Auth/Rbac/ResourceScope.cs @@ -0,0 +1,50 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Auth.Rbac; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Evaluates whether a resource identifier is permitted under a set of allow/deny glob patterns. +/// Patterns support: +/// - Exact match: "acme.sales" +/// - Prefix wildcard: "acme.sales.*" matches "acme.sales.orders" but not "acme.sales" itself +/// - Universal wildcard: "*" matches everything +/// Deny wins: a resource matching both allow and deny is denied. +/// +public sealed class ResourceScope(IReadOnlyList allow, IReadOnlyList deny) +{ + /// + /// A scope that permits all resources (allow: ["*"], deny: []). + /// + public static readonly ResourceScope Unrestricted = new(["*"], []); + + /// The allow-list patterns. + public IReadOnlyList Allow { get; } = allow; + + /// The deny-list patterns. + public IReadOnlyList Deny { get; } = deny; + + /// + /// Returns true if the resource is matched by at least one allow pattern + /// and not matched by any deny pattern. + /// + public bool Permits(string resource) => + Allow.Any(p => Matches(p, resource)) && !Deny.Any(p => Matches(p, resource)); + + static bool Matches(string pattern, string resource) + { + // Normalise both pattern and resource to lower-case. + // Queue addresses in the RavenDB index are stored lower-case; policy entries + // may be mixed-case (e.g. "Finance.*"), and in-memory resource identifiers + // (from FailedMessage.QueueAddress) may also be mixed-case. + // Lowercasing both sides ensures consistent case-insensitive comparison. + var lowerPattern = pattern.ToLowerInvariant(); + var lowerResource = resource.ToLowerInvariant(); + return lowerPattern == "*" || + lowerPattern == lowerResource || + (lowerPattern.EndsWith(".*", StringComparison.Ordinal) && + lowerResource.StartsWith(lowerPattern[..^1], StringComparison.Ordinal)); + } +} diff --git a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs index efbba73239..89388b9773 100644 --- a/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs +++ b/src/ServiceControl.Infrastructure/OpenIdConnectSettings.cs @@ -41,6 +41,8 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC ServicePulseAuthority = SettingsReader.Read(rootNamespace, "Authentication.ServicePulse.Authority"); } + RbacPolicyFile = SettingsReader.Read(rootNamespace, "Authentication.RbacPolicyFile", "rbac.yaml"); + if (validateConfiguration) { Validate(requireServicePulseSettings); @@ -53,6 +55,12 @@ public OpenIdConnectSettings(SettingsRootNamespace rootNamespace, bool validateC /// public bool Enabled { get; } + /// + /// Path to the RBAC policy file. Defaults to "rbac.yaml" resolved relative to the host executable. + /// Can be overridden via the Authentication.RbacPolicyFile setting. + /// + public string RbacPolicyFile { get; } + /// /// The OpenID Connect authority URL (issuer). This is the base URL of the identity provider /// that issues tokens (e.g., "https://login.microsoftonline.com/{tenant-id}/v2.0" for Azure AD). diff --git a/src/ServiceControl.Infrastructure/ServiceControl.Infrastructure.csproj b/src/ServiceControl.Infrastructure/ServiceControl.Infrastructure.csproj index ffdc49b0d9..5dd5d37725 100644 --- a/src/ServiceControl.Infrastructure/ServiceControl.Infrastructure.csproj +++ b/src/ServiceControl.Infrastructure/ServiceControl.Infrastructure.csproj @@ -18,6 +18,7 @@ + \ No newline at end of file diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/AcceptanceTest.cs b/src/ServiceControl.Monitoring.AcceptanceTests/AcceptanceTest.cs index aaf1849fd2..c27608af86 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/AcceptanceTest.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/AcceptanceTest.cs @@ -19,6 +19,7 @@ abstract class AcceptanceTest : NServiceBusAcceptanceTest, IAcceptanceTestInfras { public HttpClient HttpClient => serviceControlRunnerBehavior.HttpClient; public JsonSerializerOptions SerializerOptions => serviceControlRunnerBehavior.SerializerOptions; + public IServiceProvider Services => serviceControlRunnerBehavior.Services; [SetUp] public void Setup() diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs index 167d222f05..855cb21283 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs @@ -20,6 +20,7 @@ public ServiceControlComponentBehavior(ITransportIntegration transportToUse, Act public HttpClient HttpClient => runner.HttpClient; public JsonSerializerOptions SerializerOptions => runner.SerializerOptions; + public IServiceProvider Services => runner.Services; public async Task CreateRunner(RunDescriptor run) { diff --git a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs index 1d233e7cc7..9d8936f294 100644 --- a/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs +++ b/src/ServiceControl.Monitoring.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs @@ -32,6 +32,7 @@ class ServiceControlComponentRunner( public override string Name { get; } = $"{nameof(ServiceControlComponentRunner)}"; public HttpClient HttpClient { get; private set; } public JsonSerializerOptions SerializerOptions => Infrastructure.SerializerOptions.Default; + public IServiceProvider Services => host?.Services; public Task Initialize(RunDescriptor run) => InitializeServiceControl(run.ScenarioContext); diff --git a/src/ServiceControl.MultiInstance.AcceptanceTests/TestSupport/HttpExtensionsMultiinstance.cs b/src/ServiceControl.MultiInstance.AcceptanceTests/TestSupport/HttpExtensionsMultiinstance.cs index d09497c90a..f83bfa10a9 100644 --- a/src/ServiceControl.MultiInstance.AcceptanceTests/TestSupport/HttpExtensionsMultiinstance.cs +++ b/src/ServiceControl.MultiInstance.AcceptanceTests/TestSupport/HttpExtensionsMultiinstance.cs @@ -58,5 +58,10 @@ class AcceptanceTestInfrastructureProvider : IAcceptanceTestInfrastructureProvid { public HttpClient HttpClient { get; set; } public JsonSerializerOptions SerializerOptions { get; set; } + + // Services is not meaningful in the multi-instance context where the per-instance + // provider is a lightweight shim — callers that need host services should use the + // full runner directly. + public IServiceProvider Services => null; } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index df59b8fdf9..3bc6f5db40 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -1,4 +1,5 @@ -namespace ServiceControl.Persistence.RavenDB +#nullable enable +namespace ServiceControl.Persistence.RavenDB { using System; using System.Collections.Generic; @@ -17,6 +18,7 @@ using Raven.Client.Documents.Session; using ServiceControl.CompositeViews.Messages; using ServiceControl.EventLog; + using ServiceControl.Infrastructure.Auth.Rbac; using ServiceControl.MessageFailures; using ServiceControl.MessageFailures.Api; using ServiceControl.Operations; @@ -36,7 +38,7 @@ public async Task>> GetAllMessages( PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, - DateTimeRange timeSentRange + DateTimeRange? timeSentRange ) { using var session = await sessionProvider.OpenSession(); @@ -59,7 +61,7 @@ public async Task>> GetAllMessagesForEndpoint( PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, - DateTimeRange timeSentRange + DateTimeRange? timeSentRange ) { using var session = await sessionProvider.OpenSession(); @@ -84,7 +86,7 @@ public async Task>> SearchEndpointMessages( string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, - DateTimeRange timeSentRange + DateTimeRange? timeSentRange ) { using var session = await sessionProvider.OpenSession(); @@ -128,7 +130,7 @@ public async Task>> GetAllMessagesForSearch( string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, - DateTimeRange timeSentRange + DateTimeRange? timeSentRange ) { using var session = await sessionProvider.OpenSession(); @@ -215,7 +217,8 @@ public async Task>> ErrorGet( string modified, string queueAddress, PagingInfo pagingInfo, - SortInfo sortInfo + SortInfo sortInfo, + ResourceScope? queueScope = null ) { using var session = await sessionProvider.OpenSession(); @@ -225,6 +228,7 @@ SortInfo sortInfo .FilterByStatusWhere(status) .FilterByLastModifiedRange(modified) .FilterByQueueAddress(queueAddress) + .FilterByQueueScope(queueScope) .Sort(sortInfo) .Paging(pagingInfo) .SelectFields() @@ -259,7 +263,8 @@ public async Task>> ErrorsByEndpointName( string endpointName, string modified, PagingInfo pagingInfo, - SortInfo sortInfo + SortInfo sortInfo, + ResourceScope? queueScope = null ) { using var session = await sessionProvider.OpenSession(); @@ -270,6 +275,7 @@ SortInfo sortInfo .AndAlso() .WhereEquals("ReceivingEndpointName", endpointName) .FilterByLastModifiedRange(modified) + .FilterByQueueScope(queueScope) .Sort(sortInfo) .Paging(pagingInfo) .SelectFields() @@ -333,7 +339,7 @@ public async Task ErrorLastBy(string failedMessageId) var message = await session.LoadAsync(FailedMessageIdGenerator.MakeDocumentId(failedMessageId)); if (message == null) { - return null; + return null!; } var result = Map(message, session); return result; @@ -359,7 +365,7 @@ FailedMessageView Map(FailedMessage message, IAsyncDocumentSession session) NumberOfProcessingAttempts = message.ProcessingAttempts.Count, Status = message.Status, TimeOfFailure = failureDetails.TimeOfFailure, - LastModified = session.Advanced.GetLastModifiedFor(message).Value, + LastModified = session.Advanced.GetLastModifiedFor(message)!.Value, Edited = wasEdited, EditOf = wasEdited ? message.ProcessingAttempts.Last().Headers["ServiceControl.EditOf"] : "" }; @@ -413,7 +419,8 @@ public async Task>> GetGroupErrors( string status, string modified, SortInfo sortInfo, - PagingInfo pagingInfo + PagingInfo pagingInfo, + ResourceScope? queueScope = null ) { using var session = await sessionProvider.OpenSession(); @@ -423,6 +430,7 @@ PagingInfo pagingInfo .WhereEquals(view => view.FailureGroupId, groupId) .FilterByStatusWhere(status) .FilterByLastModifiedRange(modified) + .FilterByQueueScope(queueScope) .Sort(sortInfo) .Paging(pagingInfo) .SelectFields() @@ -514,7 +522,7 @@ public async Task ProcessPendingRetries(DateTime periodFrom, DateTime periodTo, class DocumentPatchResult { - public string Document { get; set; } + public string? Document { get; set; } } public async Task UnArchiveMessagesByRange(DateTime from, DateTime to) @@ -632,7 +640,7 @@ record struct FailedMessageProjection(string UniqueMessageId); public async Task FetchFromFailedMessage(string uniqueMessageId) { - byte[] body = null; + byte[]? body = null; var result = await bodyStorage.TryFetch(uniqueMessageId) ?? throw new InvalidOperationException("IBodyStorage.TryFetch result cannot be null"); @@ -651,7 +659,7 @@ public async Task FetchFromFailedMessage(string uniqueMessageId) body = memoryStream.ToArray(); } } - return body; + return body!; } public async Task StoreEventLogItem(EventLogItem logItem) diff --git a/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs b/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs index e9b86c43ec..b409c11f34 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenQueryExtensions.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ServiceControl.Persistence { using System; @@ -7,6 +8,7 @@ namespace ServiceControl.Persistence using System.Linq.Expressions; using Raven.Client.Documents.Linq; using Raven.Client.Documents.Session; + using ServiceControl.Infrastructure.Auth.Rbac; using ServiceControl.MessageFailures; using ServiceControl.Persistence.Infrastructure; @@ -35,9 +37,9 @@ public static IRavenQueryable Sort(this IRavenQueryable m => m.MessageId, "message_type" => m => m.MessageType, - "critical_time" => m => m.CriticalTime, - "delivery_time" => m => m.DeliveryTime, - "processing_time" => m => m.ProcessingTime, + "critical_time" => m => m.CriticalTime!, + "delivery_time" => m => m.DeliveryTime!, + "processing_time" => m => m.ProcessingTime!, "processed_at" => m => m.ProcessedAt, "status" => m => m.Status, _ => m => m.TimeSent, @@ -174,7 +176,7 @@ public static IAsyncDocumentQuery FilterByLastModifiedRange(this IAsyncDoc return source; } - public static IRavenQueryable FilterBySentTimeRange(this IRavenQueryable source, DateTimeRange range) + public static IRavenQueryable FilterBySentTimeRange(this IRavenQueryable source, DateTimeRange? range) { if (range == null) { @@ -207,6 +209,93 @@ public static IAsyncDocumentQuery FilterByQueueAddress(this IAsyncDocument return source; } + /// + /// Applies an RBAC queue-scope filter to the query before paging is applied, so that + /// Total-Count and page sizes reflect only messages the caller is permitted to see. + /// + /// When is the caller has an unrestricted + /// grant — no filter is added. When the allow-list contains * the query is also + /// unrestricted. An empty allow-list yields zero rows (deny-all). + /// Pattern syntax: exact match, or prefix.* (starts-with match). + /// Deny patterns are applied after allow (deny wins). + /// + /// + public static IAsyncDocumentQuery FilterByQueueScope(this IAsyncDocumentQuery source, ResourceScope? scope) + { + if (scope == null) + { + return source; + } + + // A wildcard allow pattern means unrestricted — no filter. + if (scope.Allow.Any(p => p == "*")) + { + return source; + } + + // Empty allow list → deny everything. + if (scope.Allow.Count == 0) + { + source.AndAlso(); + // WhereEquals on a non-existent value is the cleanest way to produce zero rows. + source.WhereEquals("QueueAddress", "__no-match__"); + return source; + } + + // Build the allow OR-group. + source.AndAlso(); + source.OpenSubclause(); + + var first = true; + foreach (var pattern in scope.Allow) + { + if (!first) + { + source.OrElse(); + } + + first = false; + + var lower = pattern.ToLowerInvariant(); + + if (lower.EndsWith(".*", StringComparison.Ordinal)) + { + // Prefix wildcard: "Prefix.*" → starts-with "prefix." + // Strip the trailing "*" to get the prefix including the dot. + var prefix = lower[..^1]; // e.g. "sales." from "sales.*" + source.WhereStartsWith("QueueAddress", prefix); + } + else + { + // Exact match. + source.WhereEquals("QueueAddress", lower); + } + } + + source.CloseSubclause(); + + // Apply deny patterns (AND NOT for each). Deny wins over allow. + foreach (var denyPattern in scope.Deny) + { + var lower = denyPattern.ToLowerInvariant(); + + if (lower.EndsWith(".*", StringComparison.Ordinal)) + { + // Prefix deny: AND NOT (QueueAddress STARTS WITH prefix). + var prefix = lower[..^1]; // e.g. "finance." from "finance.*" + source.AndAlso().Not.WhereStartsWith("QueueAddress", prefix); + } + else + { + // Exact deny. + source.AndAlso(); + source.WhereNotEquals("QueueAddress", lower); + } + } + + return source; + } + static string[] SplitChars = { "..." diff --git a/src/ServiceControl.Persistence/IErrorMessageDatastore.cs b/src/ServiceControl.Persistence/IErrorMessageDatastore.cs index 0dd2c6aad0..5c7d46e1d4 100644 --- a/src/ServiceControl.Persistence/IErrorMessageDatastore.cs +++ b/src/ServiceControl.Persistence/IErrorMessageDatastore.cs @@ -1,4 +1,5 @@ -namespace ServiceControl.Persistence +#nullable enable +namespace ServiceControl.Persistence { using System; using System.Collections.Generic; @@ -7,17 +8,18 @@ using Infrastructure; using MessageFailures.Api; using ServiceControl.EventLog; + using ServiceControl.Infrastructure.Auth.Rbac; using ServiceControl.MessageFailures; using ServiceControl.Operations; using ServiceControl.Recoverability; public interface IErrorMessageDataStore { - Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange timeSentRange = null); - Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange timeSentRange = null); + Task>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null); + Task>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null); Task>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages); - Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null); - Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange timeSentRange = null); + Task>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null); + Task>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null); Task FailedMessageMarkAsArchived(string failedMessageId); Task FailedMessagesFetch(Guid[] ids); Task StoreFailedErrorImport(FailedErrorImport failure); @@ -26,9 +28,22 @@ public interface IErrorMessageDataStore Task> GetFailureGroupsByClassifier(string classifier); // GetAllErrorsController - Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo); + /// + /// Returns a paged list of failed messages, optionally filtered to the caller's permitted queue scope. + /// + /// is resolved by the controller from the caller's effective RBAC grants + /// (via ) and pushed into the query before + /// paging so that the Total-Count header reflects only messages the caller is allowed to see. + /// Pass for unrestricted (admin) access. + /// + /// + Task>> ErrorGet(string status, string modified, string queueAddress, PagingInfo pagingInfo, SortInfo sortInfo, ResourceScope? queueScope = null); Task ErrorsHead(string status, string modified, string queueAddress); - Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo); + /// + /// Returns a paged list of failed messages for the specified endpoint, optionally filtered to + /// the caller's permitted queue scope — same semantics as . + /// + Task>> ErrorsByEndpointName(string status, string endpointName, string modified, PagingInfo pagingInfo, SortInfo sortInfo, ResourceScope? queueScope = null); Task> ErrorsSummary(); // GetErrorByIdController @@ -44,7 +59,11 @@ public interface IErrorMessageDataStore // FailureGroupsController Task EditComment(string groupId, string comment); Task DeleteComment(string groupId); - Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo); + /// + /// Returns a paged list of failed messages for the specified failure group, optionally filtered + /// to the caller's permitted queue scope — same semantics as . + /// + Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo, ResourceScope? queueScope = null); Task GetGroupErrorsCount(string groupId, string status, string modified); Task>> GetGroup(string groupId, string status, string modified); diff --git a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs index 1ee79cfbbc..52b8e1e1c2 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessages2Controller.cs @@ -3,9 +3,12 @@ namespace ServiceControl.CompositeViews.Messages; using System.Collections.Generic; using System.Threading.Tasks; using Infrastructure.WebApi; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; +using ServiceControl.Infrastructure.Auth.Rbac; +using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] @@ -16,6 +19,7 @@ public class GetMessages2Controller( SearchEndpointApi searchEndpointApi) : ControllerBase { + [Authorize(Policy = Permissions.MessagesView)] [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..83b2421e98 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesByConversationController.cs @@ -3,15 +3,19 @@ using System.Collections.Generic; using System.Threading.Tasks; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] public class GetMessagesByConversationController(MessagesByConversationApi byConversationApi) : ControllerBase { + [Authorize(Policy = Permissions.MessagesView)] [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..0b1d207c41 100644 --- a/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs +++ b/src/ServiceControl/CompositeViews/Messages/GetMessagesController.cs @@ -7,6 +7,7 @@ namespace ServiceControl.CompositeViews.Messages using Api.Contracts; using Infrastructure.WebApi; using MessageCounting; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -14,6 +15,8 @@ namespace ServiceControl.CompositeViews.Messages using Operations.BodyStorage; using Persistence.Infrastructure; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; using Yarp.ReverseProxy.Forwarder; // All routes matching `messages/*` must be in this controller as WebAPI cannot figure out the overlapping routes @@ -33,6 +36,7 @@ public class GetMessagesController( ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.MessagesView)] [Route("messages")] [HttpGet] public async Task> Messages([FromQuery] PagingInfo pagingInfo, @@ -48,6 +52,7 @@ public async Task> Messages([FromQuery] PagingInfo pagingInf return result.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("endpoints/{endpoint}/messages")] [HttpGet] public async Task> MessagesForEndpoint([FromQuery] PagingInfo pagingInfo, @@ -64,6 +69,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.MessagesView)] [Route("endpoints/{endpoint}/audit-count")] [HttpGet] public async Task> GetEndpointAuditCounts([FromQuery] PagingInfo pagingInfo, string endpoint) @@ -75,6 +81,7 @@ public async Task> GetEndpointAuditCounts([FromQuery] PagingIn return result.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("messages/{id}/body")] [HttpGet] public async Task Get(string id, [FromQuery(Name = "instance_id")] string instanceId) @@ -114,6 +121,7 @@ public async Task Get(string id, [FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.MessagesView)] [Route("messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -126,6 +134,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyWord([FromQuery] PagingInfo pagingInfo, @@ -139,6 +148,7 @@ public async Task> SearchByKeyWord([FromQuery] PagingInfo pa return result.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("endpoints/{endpoint}/messages/search")] [HttpGet] public async Task> Search([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, @@ -151,6 +161,7 @@ public async Task> Search([FromQuery] PagingInfo pagingInfo, return result.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("endpoints/{endpoint}/messages/search/{keyword}")] [HttpGet] public async Task> SearchByKeyword([FromQuery] PagingInfo pagingInfo, diff --git a/src/ServiceControl/Hosting/Commands/RunCommand.cs b/src/ServiceControl/Hosting/Commands/RunCommand.cs index ebc08958cf..8bd30266b9 100644 --- a/src/ServiceControl/Hosting/Commands/RunCommand.cs +++ b/src/ServiceControl/Hosting/Commands/RunCommand.cs @@ -10,6 +10,7 @@ using ServiceControl; using ServiceControl.Hosting.Auth; using ServiceControl.Hosting.Https; + using ServiceControl.Infrastructure.WebApi.Auth; using ServicePulse; class RunCommand : AbstractCommand @@ -25,6 +26,8 @@ public override async Task Execute(HostArguments args, Settings settings) var hostBuilder = WebApplication.CreateBuilder(); hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings); + hostBuilder.AddServiceControlS2Authorization(settings.OpenIdConnectSettings); hostBuilder.AddServiceControlHttps(settings.HttpsSettings); hostBuilder.AddServiceControl(settings, endpointConfiguration); hostBuilder.AddServiceControlApi(settings.CorsSettings); diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/AuthorizationHelpers.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/AuthorizationHelpers.cs new file mode 100644 index 0000000000..593697e370 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/AuthorizationHelpers.cs @@ -0,0 +1,45 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +/// +/// Shared helpers for the S2 authorization mechanism. +/// Centralises patterns used across controllers and the resource-scope checker. +/// +public static class AuthorizationHelpers +{ + /// + /// Extracts a human-readable subject identifier from the principal. + /// Prefers the sub claim; falls back to ; then "unknown". + /// + public static string GetSubject(ClaimsPrincipal user) => + user.FindFirst("sub")?.Value + ?? user.Identity?.Name + ?? "unknown"; + + /// + /// Writes a structured JSON 403 body to the HTTP response. + /// Use this whenever a resource-scope check denies access to a single resource, + /// then return Empty from the controller action. + /// + /// The current . + /// The permission that was evaluated. + /// The queue address of the resource, or if unknown. + public static async Task WriteScopeDenied403(HttpResponse response, string permission, string? queueAddress) + { + response.ContentType = "application/json"; + response.StatusCode = StatusCodes.Status403Forbidden; + await response.WriteAsJsonAsync(new + { + error = "forbidden", + permission, + resource = queueAddress, + reason = string.IsNullOrEmpty(queueAddress) + ? "Message has no resolvable queue address" + : $"Queue '{queueAddress}' is out of scope for permission '{permission}'" + }); + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionPolicyProvider.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionPolicyProvider.cs new file mode 100644 index 0000000000..3be803e765 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionPolicyProvider.cs @@ -0,0 +1,77 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// A dynamic that generates verb-level +/// authorization policies from known permission strings (e.g. messages:retry). +/// +/// When the MVC pipeline evaluates [Authorize(Policy = "messages:retry")], this provider +/// generates a policy containing a for that permission. +/// evaluates the requirement against the current user. +/// +/// +/// When OIDC is disabled, this provider returns a permissive allow-all policy for any known +/// permission name, preserving the pre-RBAC behaviour (everything allowed). +/// +/// +/// Policy names that are not known permission strings (e.g. names registered via +/// ) return so the framework +/// falls back to its default policy resolution. +/// +/// +public sealed class PermissionPolicyProvider( + IOptions authorizationOptions, + bool oidcEnabled) + : IAuthorizationPolicyProvider +{ + static readonly AuthorizationPolicy AllowAllPolicy = + new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build(); + + /// + /// Returns only when is a known permission. + /// Unknown resource:action strings that happen to contain a colon are not treated as permissions. + /// + static bool IsKnownPermission(string policyName) => + Permissions.All.Contains(policyName); + + public Task GetPolicyAsync(string policyName) + { + if (!IsKnownPermission(policyName)) + { + return Task.FromResult(null); + } + + if (!oidcEnabled) + { + // OIDC disabled → return a permissive allow-all policy so [Authorize(Policy=...)] + // attributes do not block requests when the authorization middleware is present. + return Task.FromResult(AllowAllPolicy); + } + + // OIDC enabled → build a real policy requiring the named permission. + // PermissionVerbHandler evaluates HasPermission() before the resource is loaded. + var policy = new AuthorizationPolicyBuilder() + .AddRequirements(new PermissionRequirement(policyName)) + .Build(); + + return Task.FromResult(policy); + } + + public Task GetDefaultPolicyAsync() + { + var defaultPolicy = authorizationOptions.Value.DefaultPolicy + ?? new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); + return Task.FromResult(defaultPolicy); + } + + public Task GetFallbackPolicyAsync() + { + var fallbackPolicy = authorizationOptions.Value.FallbackPolicy; + return Task.FromResult(fallbackPolicy); + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionRequirement.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionRequirement.cs new file mode 100644 index 0000000000..afea6dd1ac --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionRequirement.cs @@ -0,0 +1,23 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using Microsoft.AspNetCore.Authorization; + +/// +/// An that carries the permission string to be enforced. +/// Used by the S2 policy-attribute authorization mechanism. +/// +/// Two distinct checks use this requirement: +/// +/// Verb gate (pre-load): does the user hold at all? +/// Evaluated by via [Authorize(Policy=...)]. +/// Resource scope (post-load): is the specific resource in scope for this user? +/// Evaluated inline in the controller via . +/// +/// +/// +public sealed class PermissionRequirement(string permission) : IAuthorizationRequirement +{ + /// The permission being enforced (e.g. messages:retry). + public string Permission { get; } = permission; +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionVerbHandler.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionVerbHandler.cs new file mode 100644 index 0000000000..9b75a98717 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/PermissionVerbHandler.cs @@ -0,0 +1,68 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// S2 verb-level authorization handler for . +/// +/// This handler fires when ASP.NET Core evaluates an [Authorize(Policy = "permission")] +/// attribute — i.e., before the controller action runs and before any resource is loaded. +/// It answers the coarse question: "does this user hold the permission at all?" +/// +/// +/// In S2, the resource-scope check is performed inline in the controller via +/// , not via a typed handler. There are therefore no +/// domain-object resources passed through ; the verb handler +/// runs for every request and is the only in the S2 pipeline. +/// +/// +public sealed class PermissionVerbHandler( + IPermissionEvaluator permissionEvaluator, + IAuthorizationAuditLog auditLog) + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + // S2 uses no explicit resource-scope calls via IAuthorizationService, but guard anyway + // so the handler is harmless if code ever calls AuthorizeAsync with a resource object. + if (context.Resource is not null and not Microsoft.AspNetCore.Http.HttpContext) + { + return Task.CompletedTask; + } + + var subject = AuthorizationHelpers.GetSubject(context.User); + var permission = requirement.Permission; + + if (permissionEvaluator.HasPermission(context.User, permission)) + { + auditLog.Decision( + subject, + permission, + resource: null, + allowed: true, + reason: $"Verb-level check: user holds '{permission}'"); + + context.Succeed(requirement); + } + else + { + auditLog.Decision( + subject, + permission, + resource: null, + allowed: false, + reason: $"Verb-level check: user does not hold '{permission}'"); + + context.Fail(new AuthorizationFailureReason( + this, + $"User '{subject}' does not hold permission '{permission}'")); + } + + return Task.CompletedTask; + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/ResourceScopeChecker.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/ResourceScopeChecker.cs new file mode 100644 index 0000000000..1904836819 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/ResourceScopeChecker.cs @@ -0,0 +1,93 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// S2-specific contract for inline resource-scope checking. +/// +/// In S2 the controller calls after loading the resource, +/// passing the resolved queue address. The checker handles deny (writes the structured-403 +/// body and returns a non-null result to short-circuit) and allow (logs and returns null). +/// +/// +public interface IResourceScopeChecker +{ + /// + /// Checks whether is permitted to perform + /// on the resource identified by . + /// + /// The authenticated user principal. + /// The permission being enforced (e.g. messages:retry). + /// + /// The queue address of the resource. When null or empty the check fails closed (deny). + /// + /// The HTTP context, used to write the structured 403 body on deny. + /// + /// when access is allowed; a non-null + /// when access is denied (the 403 body has already been written to ). + /// + Task EnforceAsync( + ClaimsPrincipal user, + string permission, + string? queueAddress, + HttpContext context); +} + +/// +/// Default implementation of . +/// +public sealed class ResourceScopeChecker( + IPermissionEvaluator permissionEvaluator, + IAuthorizationAuditLog auditLog) : IResourceScopeChecker +{ + public async Task EnforceAsync( + ClaimsPrincipal user, + string permission, + string? queueAddress, + HttpContext context) + { + var subject = AuthorizationHelpers.GetSubject(user); + + // Fail closed: a resource with no resolvable queue address cannot be scope-checked. + if (string.IsNullOrEmpty(queueAddress)) + { + auditLog.Decision( + subject, + permission, + resource: null, + allowed: false, + reason: $"Resource-scope check: resource has no resolvable queue address — denying '{permission}' fail-closed"); + + await AuthorizationHelpers.WriteScopeDenied403(context.Response, permission, queueAddress: null); + return new EmptyResult(); + } + + // Resource-scope check: is this resource's queue address in scope for the user? + if (!permissionEvaluator.IsInScope(user, permission, queueAddress)) + { + auditLog.Decision( + subject, + permission, + resource: queueAddress, + allowed: false, + reason: $"Resource-scope check: queue '{queueAddress}' is out of scope for permission '{permission}'"); + + await AuthorizationHelpers.WriteScopeDenied403(context.Response, permission, queueAddress); + return new EmptyResult(); + } + + auditLog.Decision( + subject, + permission, + resource: queueAddress, + allowed: true, + reason: $"Resource-scope check: user holds '{permission}' and queue '{queueAddress}' is in scope"); + + return null; // Access allowed — controller proceeds. + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/Auth/S2AuthorizationExtensions.cs b/src/ServiceControl/Infrastructure/WebApi/Auth/S2AuthorizationExtensions.cs new file mode 100644 index 0000000000..001ee17520 --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/Auth/S2AuthorizationExtensions.cs @@ -0,0 +1,95 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi.Auth; + +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Infrastructure; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Registers the S2 policy-attribute authorization services. +/// +/// The is registered unconditionally so that +/// [Authorize(Policy = "messages:retry")] attributes do not cause "policy not found" +/// errors when OIDC is disabled. When OIDC is disabled, it returns allow-all policies. +/// +/// +/// When OIDC is disabled, an is registered so that +/// controllers that inject still resolve. The no-op +/// always returns null (allow), preserving the pre-RBAC behaviour. +/// +/// +/// Call this after AddServiceControlAuthentication in the host setup. +/// +/// +public static class S2AuthorizationExtensions +{ + public static void AddServiceControlS2Authorization( + this IHostApplicationBuilder hostBuilder, + OpenIdConnectSettings oidcSettings) + { + var services = hostBuilder.Services; + + // Register PermissionPolicyProvider unconditionally so [Authorize(Policy=...)] attributes + // do not throw "policy not found" errors regardless of OIDC being enabled or disabled. + // When oidcEnabled=false it returns allow-all policies; when true, real policies. + services.AddSingleton(sp => + new PermissionPolicyProvider( + sp.GetRequiredService>(), + oidcSettings.Enabled)); + + if (!oidcSettings.Enabled) + { + // OIDC disabled: register no-op implementations of all S2 services so controllers + // that inject IResourceScopeChecker or IPermissionEvaluator still resolve. + // Both are allow-all — the verb gate is bypassed when OIDC is disabled, so none + // of the enforcement paths are reached; but the no-ops are safe fallbacks that + // preserve pre-RBAC behaviour (no filtering, no scope restriction). + services.AddSingleton(); + services.AddSingleton(); + return; + } + + // OIDC enabled: register the real scope checker and the verb-level handler. + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// A no-op that always allows access. + /// Registered when OIDC is disabled to preserve the pre-RBAC behaviour. + /// + sealed class AllowAllResourceScopeChecker : IResourceScopeChecker + { + public Task EnforceAsync( + ClaimsPrincipal user, + string permission, + string? queueAddress, + HttpContext context) => + Task.FromResult(null); + } + + /// + /// A no-op that always allows access. + /// Registered when OIDC is disabled to preserve the pre-RBAC behaviour. + /// + /// returns (unrestricted — no filter), + /// and returns , + /// so no queue-scope filtering is applied and no fail-closed logic triggers. + /// + /// + sealed class AllowAllPermissionEvaluator : IPermissionEvaluator + { + public bool HasPermission(ClaimsPrincipal user, string permission) => true; + public bool IsInScope(ClaimsPrincipal user, string permission, string resource) => true; + public bool HasUnrestrictedGrant(ClaimsPrincipal user, string permission) => true; + public ResourceScope? ResolveQueueScope(ClaimsPrincipal user, string permission) => null; + public EffectivePermissions Resolve(ClaimsPrincipal user) => new([]); + } +} diff --git a/src/ServiceControl/Infrastructure/WebApi/AuthenticatedOnlyAttribute.cs b/src/ServiceControl/Infrastructure/WebApi/AuthenticatedOnlyAttribute.cs new file mode 100644 index 0000000000..9fa9410f9c --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/AuthenticatedOnlyAttribute.cs @@ -0,0 +1,15 @@ +namespace ServiceControl.Infrastructure.WebApi; + +using System; + +/// +/// Marks an API endpoint as reviewed: it requires authentication but no specific permission. +/// Applied to endpoints that serve user-specific data but must be accessible to any authenticated user +/// (e.g. GET /api/me/permissions). +/// +/// The endpoint-completeness test () +/// accepts this attribute as an explicit opt-out from per-permission enforcement. +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class AuthenticatedOnlyAttribute : Attribute; diff --git a/src/ServiceControl/Infrastructure/WebApi/MePermissionsController.cs b/src/ServiceControl/Infrastructure/WebApi/MePermissionsController.cs new file mode 100644 index 0000000000..655c5e5bfa --- /dev/null +++ b/src/ServiceControl/Infrastructure/WebApi/MePermissionsController.cs @@ -0,0 +1,77 @@ +#nullable enable +namespace ServiceControl.Infrastructure.WebApi; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Infrastructure.Auth.Rbac; + +/// +/// Exposes the calling user's effective permission set. +/// Any authenticated user may query their own permissions — no specific permission is required. +/// ServicePulse consumes this endpoint to decide which UI controls to show. +/// +/// When OIDC is disabled, is not registered in the DI container +/// and this endpoint returns 404 Not Found, preserving the non-breaking guarantee that +/// deployments without OIDC behave identically to pre-RBAC versions (spec §4). +/// +/// +[ApiController] +[Route("api")] +[AuthenticatedOnly] +public class MePermissionsController(IServiceProvider serviceProvider) : ControllerBase +{ + /// + /// Returns the effective permissions for the currently authenticated user. + /// + /// The user's effective permissions. + /// No valid bearer token was provided. + /// OIDC / authorization is disabled — this endpoint does not exist in this deployment. + [HttpGet] + [Route("me/permissions")] + public ActionResult GetMyPermissions() + { + // Resolve optionally: IPermissionEvaluator is only registered when OIDC is enabled. + // Return 404 when auth is disabled so the endpoint is effectively absent, matching + // the non-breaking guarantee (spec §4) — no 500 from a failed mandatory DI resolution. + var permissionEvaluator = serviceProvider.GetService(); + var policyFactory = serviceProvider.GetService>(); + + if (permissionEvaluator == null || policyFactory == null) + { + return NotFound(); + } + + var policy = policyFactory(); + var effective = permissionEvaluator.Resolve(User); + + var permissions = effective.Grants + .Select(g => new PermissionEntry( + g.Permission, + g.Scope != null + ? new ScopeDescriptor(g.Scope.Allow, g.Scope.Deny) + : null)) + .ToList(); + + var descriptor = new PermissionsDescriptor( + Version: policy.LoadedAt, + User: User.FindFirst("sub")?.Value ?? User.Identity?.Name ?? "unknown", + Permissions: permissions); + + return Ok(descriptor); + } +} + +/// The JSON shape returned by GET /api/me/permissions. +public sealed record PermissionsDescriptor( + DateTimeOffset Version, + string User, + IReadOnlyList Permissions); + +/// A single effective permission grant in the descriptor response. +public sealed record PermissionEntry(string Permission, ScopeDescriptor? Scope); + +/// The allow/deny scope attached to a permission entry (null = unrestricted). +public sealed record ScopeDescriptor(IReadOnlyList Allow, IReadOnlyList Deny); diff --git a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs index bad1ec4cf5..962291638b 100644 --- a/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ArchiveMessagesController.cs @@ -4,8 +4,11 @@ namespace ServiceControl.MessageFailures.Api using System.Threading.Tasks; using Infrastructure.WebApi; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; using ServiceControl.Persistence; using ServiceControl.Recoverability; @@ -13,6 +16,7 @@ namespace ServiceControl.MessageFailures.Api [Route("api")] public class ArchiveMessagesController(IMessageSession messageSession, IErrorMessageDataStore dataStore) : ControllerBase { + [Authorize(Policy = Permissions.MessagesArchive)] [Route("errors/archive")] [HttpPost] [HttpPatch] @@ -34,6 +38,7 @@ public async Task ArchiveBatch(string[] messageIds) return Accepted(); } + [Authorize(Policy = Permissions.MessagesView)] [Route("errors/groups/{classifier?}")] [HttpGet] public async Task GetArchiveMessageGroups(string classifier = "Exception Type and Stack Trace") @@ -45,16 +50,23 @@ public async Task GetArchiveMessageGroups(string classifier = "Ex return Ok(results); } + [Authorize(Policy = Permissions.MessagesArchive)] [Route("errors/{messageId:required:minlength(1)}/archive")] [HttpPost] [HttpPatch] public async Task Archive(string messageId) { + // NOTE: No per-message resource-scope check here. The archive operation is fire-and-forget via + // SendLocal — the message is enqueued without loading the FailedMessage first, so there is no + // queue address available to scope-check at this point. The verb gate (messages:archive) is + // enforced above; resource-scope enforcement for archive would require loading before enqueue, + // which is a breaking change to the handler chain. Deferred to a future phase. await messageSession.SendLocal(m => m.FailedMessageId = messageId); return Accepted(); } + [Authorize(Policy = Permissions.MessagesView)] [Route("archive/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task> GetGroup(string groupId, string status = default, string modified = default) @@ -66,4 +78,4 @@ public async Task> GetGroup(string groupId, strin return result.Results; } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs index 3ce42fc3ff..9b4b5f7de4 100644 --- a/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/EditFailedMessagesController.cs @@ -1,16 +1,20 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NServiceBus; using Persistence; using Recoverability; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] @@ -18,13 +22,16 @@ public class EditFailedMessagesController( Settings settings, IErrorMessageDataStore store, IMessageSession session, + IResourceScopeChecker resourceScopeChecker, ILogger logger) : ControllerBase { + [Authorize(Policy = Permissions.MessagesEdit)] [Route("edit/config")] [HttpGet] public EditConfigurationModel Config() => GetEditConfiguration(); + [Authorize(Policy = Permissions.MessagesEdit)] [Route("edit/{failedMessageId:required:minlength(1)}")] [HttpPost] public async Task> Edit(string failedMessageId, [FromBody] EditMessageModel edit) @@ -53,6 +60,20 @@ public async Task> Edit(string failedMessageId, return BadRequest(); } + // Resource-scope check: is this message's queue address in scope for this user? + var queueAddress = failedMessage.ProcessingAttempts + .LastOrDefault() + ?.FailureDetails + ?.AddressOfFailingEndpoint; + + var scopeDeny = await resourceScopeChecker.EnforceAsync( + User, Permissions.MessagesEdit, queueAddress, HttpContext); + + if (scopeDeny != null) + { + return Empty; + } + //WARN /* * failedMessage.ProcessingAttempts.Last() return the last retry attempt. @@ -152,4 +173,4 @@ public class EditRetryResponse { public bool EditIgnored { get; set; } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs index 60f9f08ca9..c69e78e088 100644 --- a/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetAllErrorsController.cs @@ -1,26 +1,38 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { using System.Collections.Generic; using System.Threading.Tasks; using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; using ServiceControl.Persistence; [ApiController] [Route("api")] - public class GetAllErrorsController(IErrorMessageDataStore store) : ControllerBase + public class GetAllErrorsController( + IErrorMessageDataStore store, + IPermissionEvaluator permissionEvaluator) : ControllerBase { + [Authorize(Policy = Permissions.MessagesView)] [Route("errors")] [HttpGet] public async Task> ErrorsGet([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string queueAddress) { + // R1: resolve the caller's permitted queue scope and push it into the query before + // paging, so that Total-Count and page sizes reflect only messages in scope. + // Null means unrestricted (admin / no scoped grants). + var queueScope = permissionEvaluator.ResolveQueueScope(User, Permissions.MessagesView); + var results = await store.ErrorGet( status: status, modified: modified, queueAddress: queueAddress, pagingInfo, - sortInfo + sortInfo, + queueScope ); Response.WithQueryStatsAndPagingInfo(results.QueryStats, pagingInfo); @@ -28,6 +40,7 @@ public async Task> ErrorsGet([FromQuery] PagingInfo pag return results.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("errors")] [HttpHead] public async Task ErrorsHead(string status, string modified, string queueAddress) @@ -41,16 +54,22 @@ public async Task ErrorsHead(string status, string modified, string queueAddress Response.WithQueryStatsInfo(queryResult); } + [Authorize(Policy = Permissions.MessagesView)] [Route("endpoints/{endpointname}/errors")] [HttpGet] public async Task> ErrorsByEndpointName([FromQuery] PagingInfo pagingInfo, [FromQuery] SortInfo sortInfo, string status, string modified, string endpointName) { + // R1: resolve the caller's permitted queue scope and push it into the query before + // paging, so that Total-Count and page sizes reflect only messages in scope. + var queueScope = permissionEvaluator.ResolveQueueScope(User, Permissions.MessagesView); + var results = await store.ErrorsByEndpointName( status: status, endpointName: endpointName, modified: modified, pagingInfo, - sortInfo + sortInfo, + queueScope ); Response.WithQueryStatsAndPagingInfo(results.QueryStats, pagingInfo); @@ -58,8 +77,9 @@ public async Task> ErrorsByEndpointName([FromQuery] Pag return results.Results; } + [Authorize(Policy = Permissions.MessagesView)] [Route("errors/summary")] [HttpGet] public async Task> ErrorsSummary() => await store.ErrorsSummary(); } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs index 437b6fc5a3..993e9cf459 100644 --- a/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs +++ b/src/ServiceControl/MessageFailures/Api/GetErrorByIdController.cs @@ -1,29 +1,73 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { + using System.Linq; using System.Threading.Tasks; + using Infrastructure.WebApi; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] - public class GetErrorByIdController(IErrorMessageDataStore store) : ControllerBase + public class GetErrorByIdController( + IErrorMessageDataStore store, + IResourceScopeChecker resourceScopeChecker) : ControllerBase { + [Authorize(Policy = Permissions.MessagesView)] [Route("errors/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorBy(string failedMessageId) { var result = await store.ErrorBy(failedMessageId); - return result == null ? NotFound() : result; + if (result == null) + { + return NotFound(); + } + + // Resource-scope check: is this message's queue address in scope for this user? + // IResourceScopeChecker writes the structured 403 body on deny and returns non-null. + var queueAddress = result.ProcessingAttempts + .LastOrDefault() + ?.FailureDetails + ?.AddressOfFailingEndpoint; + + var scopeDeny = await resourceScopeChecker.EnforceAsync( + User, Permissions.MessagesView, queueAddress, HttpContext); + + if (scopeDeny != null) + { + return Empty; + } + + return result; } + [Authorize(Policy = Permissions.MessagesView)] [Route("errors/last/{failedMessageId:required:minlength(1)}")] [HttpGet] public async Task> ErrorLastBy(string failedMessageId) { var result = await store.ErrorLastBy(failedMessageId); - return result == null ? NotFound() : result; + if (result == null) + { + return NotFound(); + } + + // Resource-scope check: consistent with ErrorBy — a scoped user must not view + // a message whose queue is outside their scope. + var scopeDeny = await resourceScopeChecker.EnforceAsync( + User, Permissions.MessagesView, result.QueueAddress, HttpContext); + + if (scopeDeny != null) + { + return Empty; + } + + return result; } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs index 611ee01e5c..4f2a381328 100644 --- a/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/PendingRetryMessagesController.cs @@ -1,4 +1,4 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { using System; using System.ComponentModel.DataAnnotations; @@ -6,13 +6,18 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] public class PendingRetryMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.MessagesRetry)] [Route("pendingretries/retry")] [HttpPost] public async Task RetryBy(string[] ids) @@ -28,6 +33,7 @@ public async Task RetryBy(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("pendingretries/queues/retry")] [HttpPost] public async Task RetryBy(PendingRetryRequest request) @@ -55,4 +61,4 @@ public class PendingRetryRequest public DateTime To { get; set; } } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs index dc4eddf431..db6d35e66d 100644 --- a/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/ResolveMessagesController.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable namespace ServiceControl.MessageFailures.Api { @@ -9,14 +9,19 @@ namespace ServiceControl.MessageFailures.Api using System.Text.Json.Serialization; using System.Threading.Tasks; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] public class ResolveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.MessagesRetry)] [Route("pendingretries/resolve")] [HttpPatch] public async Task ResolveBy(UniqueMessageIdsModel request) @@ -61,6 +66,7 @@ await session.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("pendingretries/queues/resolve")] [HttpPatch] public async Task ResolveBy(QueueModel queueModel) @@ -100,4 +106,4 @@ public class QueueModel public DateTime To { get; set; } } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs index c486129df9..3011493d30 100644 --- a/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/RetryMessagesController.cs @@ -1,10 +1,11 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,10 @@ using NServiceBus; using Recoverability; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; + using ServiceControl.Persistence; using Yarp.ReverseProxy.Forwarder; [ApiController] @@ -21,14 +26,45 @@ public class RetryMessagesController( HttpMessageInvoker httpMessageInvoker, IHttpForwarder forwarder, IMessageSession messageSession, + IErrorMessageDataStore errorMessageDataStore, + IResourceScopeChecker resourceScopeChecker, ILogger logger) : ControllerBase { + /// + /// Retries a single failed message by its ID. + /// Requires messages:retry permission (verb gate via policy), + /// plus an inline resource-scope check against the message's queue address. + /// + [Authorize(Policy = Permissions.MessagesRetry)] [Route("errors/{failedMessageId:required:minlength(1)}/retry")] [HttpPost] public async Task RetryMessageBy([FromQuery(Name = "instance_id")] string instanceId, string failedMessageId) { if (string.IsNullOrWhiteSpace(instanceId) || instanceId == settings.InstanceId) { + // Local retry: load the message to perform the resource-scope check. + var message = await errorMessageDataStore.ErrorBy(failedMessageId); + if (message == null) + { + return NotFound(); + } + + // Resource-scope check: is this message's queue address in scope for this user? + // IResourceScopeChecker writes the structured 403 body on deny and returns non-null. + var queueAddress = message.ProcessingAttempts + .LastOrDefault() + ?.FailureDetails + ?.AddressOfFailingEndpoint; + + var scopeDeny = await resourceScopeChecker.EnforceAsync( + User, Permissions.MessagesRetry, queueAddress, HttpContext); + + if (scopeDeny != null) + { + // Response body already written by the checker; return Empty to suppress MVC output. + return Empty; + } + await messageSession.SendLocal(m => m.FailedMessageId = failedMessageId); return Accepted(); } @@ -40,6 +76,7 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") return BadRequest(); } + // Remote instance: forward the request — the remote instance enforces its own authorization. var forwarderError = await forwarder.SendAsync(HttpContext, remote.BaseAddress, httpMessageInvoker); if (forwarderError != ForwarderError.None && HttpContext.GetForwarderErrorFeature()?.Exception is { } exception) { @@ -49,6 +86,7 @@ public async Task RetryMessageBy([FromQuery(Name = "instance_id") return Empty; } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("errors/retry")] [HttpPost] public async Task RetryAllBy(List messageIds) @@ -63,6 +101,7 @@ public async Task RetryAllBy(List messageIds) return Accepted(); } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("errors/queues/{queueAddress:required:minlength(1)}/retry")] [HttpPost] public async Task RetryAllBy(string queueAddress) @@ -76,6 +115,7 @@ await messageSession.SendLocal(m => return Accepted(); } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("errors/retry/all")] [HttpPost] public async Task RetryAll() @@ -85,6 +125,7 @@ public async Task RetryAll() return Accepted(); } + [Authorize(Policy = Permissions.MessagesRetry)] [Route("errors/{endpointName:required:minlength(1)}/retry/all")] [HttpPost] public async Task RetryAllByEndpoint(string endpointName) @@ -94,4 +135,4 @@ public async Task RetryAllByEndpoint(string endpointName) return Accepted(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs index 0a0271d9fa..51f88af935 100644 --- a/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs +++ b/src/ServiceControl/MessageFailures/Api/UnArchiveMessagesController.cs @@ -1,17 +1,22 @@ -namespace ServiceControl.MessageFailures.Api +namespace ServiceControl.MessageFailures.Api { using System; using System.Globalization; using System.Linq; using System.Threading.Tasks; using InternalMessages; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; [ApiController] [Route("api")] public class UnArchiveMessagesController(IMessageSession session) : ControllerBase { + [Authorize(Policy = Permissions.MessagesUnarchive)] [Route("errors/unarchive")] [HttpPatch] public async Task Unarchive(string[] ids) @@ -28,6 +33,7 @@ public async Task Unarchive(string[] ids) return Accepted(); } + [Authorize(Policy = Permissions.MessagesUnarchive)] [Route("errors/{from}...{to}/unarchive")] [HttpPatch] public async Task Unarchive(string from, string to) @@ -49,4 +55,4 @@ public async Task Unarchive(string from, string to) return Accepted(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs index f40611db85..5614a8008e 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsArchiveController.cs @@ -1,18 +1,38 @@ -namespace ServiceControl.Recoverability.API +namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; + using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; [ApiController] [Route("api")] - public class FailureGroupsArchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase + public class FailureGroupsArchiveController(IMessageSession bus, IArchiveMessages archiver, IPermissionEvaluator permissionEvaluator) : ControllerBase { + [Authorize(Policy = Permissions.RecoverabilityGroupsArchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/archive")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) { + // S2 fail-closed for group operations: groups span multiple queues and cannot be + // cleanly scope-checked against a single queue address. An unrestricted grant + // (no scope restriction) is required to operate on groups. + // + // v1 documented limitation: scoped users must use per-message archive operations. + if (!permissionEvaluator.HasUnrestrictedGrant(User, Permissions.RecoverabilityGroupsArchive)) + { + await AuthorizationHelpers.WriteScopeDenied403( + Response, + Permissions.RecoverabilityGroupsArchive, + queueAddress: groupId); + return Empty; + } + if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartArchiving(groupId, ArchiveType.FailureGroup); @@ -23,4 +43,4 @@ public async Task ArchiveGroupErrors(string groupId) return Accepted(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs index b03b2b702d..036f341a9f 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsController.cs @@ -1,12 +1,15 @@ -namespace ServiceControl.Recoverability.API +namespace ServiceControl.Recoverability.API { using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Infrastructure.WebApi; using MessageFailures.Api; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Persistence.Infrastructure; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi.Auth; using ServiceControl.Persistence; [ApiController] @@ -15,9 +18,11 @@ public class FailureGroupsController( IEnumerable classifiers, GroupFetcher fetcher, IErrorMessageDataStore store, - IRetryHistoryDataStore retryStore) + IRetryHistoryDataStore retryStore, + IPermissionEvaluator permissionEvaluator) : ControllerBase { + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/classifiers")] [HttpGet] public string[] GetSupportedClassifiers() @@ -32,24 +37,53 @@ public string[] GetSupportedClassifiers() return result; } + /// + /// Adds or updates a comment for the specified failure group. + /// + /// Fail-closed for scoped users: failure groups span multiple queues and cannot be + /// scope-checked against a single queue address. If the user holds only scope-restricted + /// grants for recoverabilitygroups:view, access is denied with 403. + /// + /// + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpPost] public async Task EditComment(string groupId, string comment) { + var scopeDenied = await EnforceGroupScopeAsync(groupId, Permissions.RecoverabilityGroupsView); + if (scopeDenied != null) + { + return scopeDenied; + } + await store.EditComment(groupId, comment); return Accepted(); } + /// + /// Deletes the comment for the specified failure group. + /// + /// Fail-closed for scoped users: same semantics as . + /// + /// + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/comment")] [HttpDelete] public async Task DeleteComment(string groupId) { + var scopeDenied = await EnforceGroupScopeAsync(groupId, Permissions.RecoverabilityGroupsView); + if (scopeDenied != null) + { + return scopeDenied; + } + await store.DeleteComment(groupId); return Accepted(); } + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/groups/{classifier?}")] [HttpGet] public async Task GetAllGroups(string classifier = "Exception Type and Stack Trace", string classifierFilter = default) @@ -64,17 +98,22 @@ public async Task GetAllGroups(string classifier = "Exception return results; } + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [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) { - var results = await store.GetGroupErrors(groupId, status, modified, sortInfo, pagingInfo); + // R1: resolve the caller's permitted queue scope and push it into the query before + // paging, so that Total-Count and page sizes reflect only messages in scope. + var queueScope = permissionEvaluator.ResolveQueueScope(User, Permissions.RecoverabilityGroupsView); + + var results = await store.GetGroupErrors(groupId, status, modified, sortInfo, pagingInfo, queueScope); Response.WithQueryStatsAndPagingInfo(results.QueryStats, pagingInfo); return results.Results; } - + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors")] [HttpHead] public async Task GetGroupErrorsCount(string groupId, string status = default, string modified = default) @@ -84,6 +123,7 @@ public async Task GetGroupErrorsCount(string groupId, string status = default, s Response.WithQueryStatsInfo(results); } + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/history")] [HttpGet] public async Task GetRetryHistory() @@ -95,6 +135,7 @@ public async Task GetRetryHistory() return retryHistory; } + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/groups/id/{groupId:required:minlength(1)}")] [HttpGet] public async Task GetGroup(string groupId, string status = default, string modified = default) @@ -105,5 +146,23 @@ public async Task GetGroup(string groupId, string status = def return result.Results.FirstOrDefault(); } + + /// + /// Fail-closed scope check for group operations. Groups span multiple queues and cannot + /// be scope-checked against a single queue address, so scoped users are denied. + /// + async Task EnforceGroupScopeAsync(string groupId, string permission) + { + if (!permissionEvaluator.HasUnrestrictedGrant(User, permission)) + { + await AuthorizationHelpers.WriteScopeDenied403( + Response, + permission, + queueAddress: groupId); + return new EmptyResult(); + } + + return null; + } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs index 308d427217..203960863e 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsRetryController.cs @@ -2,18 +2,38 @@ namespace ServiceControl.Recoverability.API { using System; using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; using ServiceControl.Persistence; [ApiController] [Route("api")] - public class FailureGroupsRetryController(IMessageSession bus, RetryingManager retryingManager) : ControllerBase + public class FailureGroupsRetryController(IMessageSession bus, RetryingManager retryingManager, IPermissionEvaluator permissionEvaluator) : ControllerBase { + [Authorize(Policy = Permissions.RecoverabilityGroupsRetry)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/retry")] [HttpPost] public async Task ArchiveGroupErrors(string groupId) { + // S2 fail-closed for group operations: groups span multiple queues and cannot be + // cleanly scope-checked against a single queue address. An unrestricted grant + // (no scope restriction) is required to operate on groups. + // + // v1 documented limitation: scoped users must use per-message retry operations. + // Unrestricted users (sc-admin with "*" or sc-operator with no scope) proceed normally. + if (!permissionEvaluator.HasUnrestrictedGrant(User, Permissions.RecoverabilityGroupsRetry)) + { + await AuthorizationHelpers.WriteScopeDenied403( + Response, + Permissions.RecoverabilityGroupsRetry, + queueAddress: groupId); + return Empty; + } + var started = DateTime.UtcNow; if (!retryingManager.IsOperationInProgressFor(groupId, RetryType.FailureGroup)) @@ -30,4 +50,4 @@ await bus.SendLocal(new RetryAllInGroup return Accepted(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs index 5661c8dd78..de6157ce07 100644 --- a/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs +++ b/src/ServiceControl/Recoverability/API/FailureGroupsUnarchiveController.cs @@ -1,18 +1,38 @@ -namespace ServiceControl.Recoverability.API +namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NServiceBus; + using ServiceControl.Infrastructure.Auth.Rbac; + using ServiceControl.Infrastructure.WebApi; + using ServiceControl.Infrastructure.WebApi.Auth; + using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; [ApiController] [Route("api")] - public class FailureGroupsUnarchiveController(IMessageSession bus, IArchiveMessages archiver) : ControllerBase + public class FailureGroupsUnarchiveController(IMessageSession bus, IArchiveMessages archiver, IPermissionEvaluator permissionEvaluator) : ControllerBase { + [Authorize(Policy = Permissions.RecoverabilityGroupsUnarchive)] [Route("recoverability/groups/{groupId:required:minlength(1)}/errors/unarchive")] [HttpPost] public async Task UnarchiveGroupErrors(string groupId) { + // S2 fail-closed for group operations: groups span multiple queues and cannot be + // cleanly scope-checked against a single queue address. An unrestricted grant + // (no scope restriction) is required to operate on groups. + // + // v1 documented limitation: scoped users must use per-message unarchive operations. + if (!permissionEvaluator.HasUnrestrictedGrant(User, Permissions.RecoverabilityGroupsUnarchive)) + { + await AuthorizationHelpers.WriteScopeDenied403( + Response, + Permissions.RecoverabilityGroupsUnarchive, + queueAddress: groupId); + return Empty; + } + if (!archiver.IsOperationInProgressFor(groupId, ArchiveType.FailureGroup)) { await archiver.StartUnarchiving(groupId, ArchiveType.FailureGroup); @@ -23,4 +43,4 @@ public async Task UnarchiveGroupErrors(string groupId) return Accepted(); } } -} \ No newline at end of file +} diff --git a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs index 2e85be691f..cb9964a84e 100644 --- a/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs +++ b/src/ServiceControl/Recoverability/API/UnacknowledgedGroupsController.cs @@ -1,7 +1,9 @@ namespace ServiceControl.Recoverability.API { using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using ServiceControl.Infrastructure.Auth.Rbac; using ServiceControl.Persistence; using ServiceControl.Persistence.Recoverability; @@ -9,6 +11,7 @@ [Route("api")] public class UnacknowledgedGroupsController(IRetryHistoryDataStore retryStore, IArchiveMessages archiver) : ControllerBase { + [Authorize(Policy = Permissions.RecoverabilityGroupsView)] [Route("recoverability/unacknowledgedgroups/{groupId:required:minlength(1)}")] [HttpDelete] public async Task AcknowledgeOperation(string groupId) diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index d931751d34..7400a606f9 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -46,6 +46,12 @@ + + + PreserveNewest + + + diff --git a/src/ServiceControl/rbac.yaml b/src/ServiceControl/rbac.yaml new file mode 100644 index 0000000000..e4c7671a43 --- /dev/null +++ b/src/ServiceControl/rbac.yaml @@ -0,0 +1,71 @@ +schemaVersion: 1 +roles: + sc-admin: + bindings: [ "role:sc-admin" ] + permissions: [ "*" ] + sc-operator: + bindings: [ "role:sc-operator" ] + permissions: + # Messages + - "messages:view" + - "messages:retry" + - "messages:archive" + - "messages:unarchive" + - "messages:edit" + # Recoverability groups + - "recoverabilitygroups:view" + - "recoverabilitygroups:retry" + - "recoverabilitygroups:archive" + - "recoverabilitygroups:unarchive" + # Endpoints + - "endpoints:view" + - "endpoints:manage" + - "endpoints:delete" + # Heartbeats + - "heartbeats:view" + # Custom checks + - "customchecks:view" + - "customchecks:delete" + # Sagas + - "sagas:view" + # Event log + - "eventlog:view" + # Licensing — view only (manage is admin-only) + - "licensing:view" + # Notifications + - "notifications:view" + - "notifications:manage" + - "notifications:test" + # Retry redirects + - "redirects:view" + - "redirects:manage" + # Queue addresses + - "queues:view" + - "queues:delete" + # Throughput + - "throughput:view" + - "throughput:manage" + # Platform connections + - "connections:view" + - "connections:manage" + # Monitoring & audit (read-only) + - "monitoring:view" + - "audit:view" + sc-viewer: + bindings: [ "role:sc-viewer" ] + permissions: + - "messages:view" + - "recoverabilitygroups:view" + - "endpoints:view" + - "heartbeats:view" + - "customchecks:view" + - "sagas:view" + - "eventlog:view" + - "licensing:view" + - "notifications:view" + - "redirects:view" + - "queues:view" + - "throughput:view" + - "connections:view" + - "monitoring:view" + - "audit:view"