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"