Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
6125b09
Add permissions catalog with constants for access control
ramonsmits Jun 2, 2026
e1082ad
1st pass of registering an authorization provider
ramonsmits Jun 4, 2026
9cb0f09
✨ Per-IdP roles claim path via IClaimsTransformation
ramonsmits Jun 4, 2026
5ae3c44
🐛 Authorize policies require authenticated user
ramonsmits Jun 5, 2026
8e06d2a
🐛 Wire authorization in acceptance-test runners
ramonsmits Jun 5, 2026
99923fe
🐛 Accept-token tests must supply a role-bearing claim
ramonsmits Jun 5, 2026
746cefb
✅ Approve RolesClaim in PlatformSampleSettings snapshots
ramonsmits Jun 5, 2026
387a416
Fix rebase issues
WilliamBZA Jun 5, 2026
b29e1b1
magic number
ramonsmits Jun 5, 2026
41dcf2a
🐛 Always register the permission policy provider
ramonsmits Jun 5, 2026
95c6c63
🐛 OIDC enabled tests must enable RBAC explicitly
ramonsmits Jun 5, 2026
89977de
✅ Refresh PlatformSampleSettings snapshots for RBAC settings
ramonsmits Jun 5, 2026
af98ef4
magic number
ramonsmits Jun 5, 2026
1811c14
Make role based always on, but only filter if auth enabled
WilliamBZA Jun 8, 2026
b7ad2e4
Merge pull request #5519 from Particular/roleclaimconfigurable
ramonsmits Jun 10, 2026
467baa3
Merge remote-tracking branch 'origin/master' into auth
ramonsmits Jun 11, 2026
a28df24
Audit log for authorization decisions (#5520)
ramonsmits Jun 19, 2026
febce88
Add service.name/version to OTLP log resource (#5536)
ramonsmits Jun 22, 2026
3825fc2
Expose a per-instance my/routes authorization manifest (#5538)
WilliamBZA Jul 1, 2026
92db56c
✨ Add AuditUser and message-action enums
ramonsmits Jul 1, 2026
c5a031c
✨ Add MessageActionAuditLog ECS emitter
ramonsmits Jul 1, 2026
757b737
✨ Add ICurrentUserAccessor for audit identity resolution
ramonsmits Jul 1, 2026
8aa8747
✨ Add AuditHeaders identity stamp/read seam
ramonsmits Jul 1, 2026
9280bec
✨ Register message-action audit services
ramonsmits Jul 1, 2026
fb04b91
✅ Guard that ServiceControl.Audit.Messages routes to the audit sink
ramonsmits Jul 1, 2026
3bdbb51
✨ Audit group-retry operations
ramonsmits Jul 1, 2026
c07fd56
✨ Audit group-archive and group-unarchive operations
ramonsmits Jul 1, 2026
70a6e2c
✨ Audit direct retry operations
ramonsmits Jul 1, 2026
44fe317
✨ Audit archive, unarchive, and pending-retry operations
ramonsmits Jul 1, 2026
a9b6151
✅ Strengthen batch-audit test assertions (count/resource)
ramonsmits Jul 1, 2026
c4cd4bd
✨ Audit per-message entries for batch id operations
ramonsmits Jul 1, 2026
68a5706
✅ File-scoped namespaces and per-message coverage for audit tests
ramonsmits Jul 1, 2026
000f47e
Replace wildcard role-permission expansion with explicit lists (#5571)
ramonsmits Jul 2, 2026
e98efda
Pin the my/routes manifest to one wire shape across instances (#5573)
dvdstelt Jul 3, 2026
6699e1c
Include user roles in routes manifest (#5574)
WilliamBZA Jul 3, 2026
7f5f2ca
✨ Audit each retried/archived message at execution time
ramonsmits Jul 3, 2026
7cbeb5c
♻️ Omit null-valued properties from audit ECS documents
ramonsmits Jul 3, 2026
6b8c062
⚜️ Use file-scoped namespaces for new audit files
ramonsmits Jul 3, 2026
b869ba1
Audit edit operations
WilliamBZA Jul 6, 2026
c7d5f50
Update assertions
WilliamBZA Jul 6, 2026
b89f22e
Use correct permissions for roles (#5578)
WilliamBZA Jul 6, 2026
92d6f94
Remove some unneeded white box tests
ramonsmits Jul 6, 2026
24bac90
Use File-scoped namespaces
ramonsmits Jul 6, 2026
f0b1547
Trying to fix format
dvdstelt Jul 6, 2026
dc7258e
🐛 Register IMessageActionAuditLog in all hosts that consume the input…
ramonsmits Jul 6, 2026
a2321a5
🐛 Keep audit attribution on archive operation documents across batches
ramonsmits Jul 6, 2026
5a413f5
🐛 Audit pending retries at staging time instead of intent time
ramonsmits Jul 6, 2026
f9fa13d
🐛 Keep the remote instance's Request-Id on forwarded responses
ramonsmits Jul 6, 2026
d74676b
🐛 Don't audit group operations that are skipped as already in progress
ramonsmits Jul 6, 2026
2350e6f
🐛 Record the real outcome on operation audit entries
ramonsmits Jul 6, 2026
b21b402
🐛 Audit an edit only after the edited message is dispatched
ramonsmits Jul 6, 2026
4ab9d35
🐛 Carry the batch scope on ArchiveMessage per-message audit entries
ramonsmits Jul 6, 2026
b1ce6b1
🐛 Deflake AllMessagesInUnArchivedGroupShouldNotExpire
ramonsmits Jul 6, 2026
252e333
⚡️ Skip building ECS audit documents for filtered-out categories
ramonsmits Jul 6, 2026
df2f4ed
⚡️ Export the audit documents as the OTLP record body
ramonsmits Jul 6, 2026
000c8d2
Merge pull request #5570 from Particular/audit-message-actions
dvdstelt Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.9" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.9" />
<PackageVersion Include="Validar.Fody" Version="1.9.0" />
<PackageVersion Include="Verify.NUnit" Version="31.20.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup Label="Versions to pin transitive references">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\Particular.LicensingComponent.Persistence\Particular.LicensingComponent.Persistence.csproj" />
<ProjectReference Include="..\ServiceControl.Api\ServiceControl.Api.csproj" />
<ProjectReference Include="..\ServiceControl.Infrastructure\ServiceControl.Infrastructure.csproj" />
<ProjectReference Include="..\ServiceControl.Transports\ServiceControl.Transports.csproj" />
</ItemGroup>

Expand Down
10 changes: 10 additions & 0 deletions src/Particular.LicensingComponent/WebApi/LicensingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
using System.Text.Json;
using System.Threading;
using Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Particular.LicensingComponent.Report;
using ServiceControl.Infrastructure.Auth;

[ApiController]
[Route("api/licensing")]
Expand All @@ -19,13 +21,15 @@ public LicensingController(IThroughputCollector throughputCollector)
this.throughputCollector = throughputCollector;
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("endpoints")]
[HttpGet]
public async Task<List<EndpointThroughputSummary>> GetEndpointThroughput(CancellationToken cancellationToken)
{
return await throughputCollector.GetThroughputSummary(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputManage)]
[Route("endpoints/update")]
[HttpPost]
public async Task<IActionResult> UpdateUserSelectionOnEndpointThroughput(List<UpdateUserIndicator> updateUserIndicators, CancellationToken cancellationToken)
Expand All @@ -34,13 +38,15 @@ public async Task<IActionResult> UpdateUserSelectionOnEndpointThroughput(List<Up
return Ok();
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("report/available")]
[HttpGet]
public async Task<ReportGenerationState> CanThroughputReportBeGenerated(CancellationToken cancellationToken)
{
return await throughputCollector.GetReportGenerationState(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("report/file")]
[HttpGet]
public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken)
Expand Down Expand Up @@ -77,24 +83,28 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string
await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/info")]
[HttpGet]
public async Task<ThroughputConnectionSettings> GetThroughputSettingsInformation(CancellationToken cancellationToken)
{
return await throughputCollector.GetThroughputConnectionSettingsInformation(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/test")]
[HttpGet]
public async Task<ConnectionTestResults> TestThroughputConnectionSettings(CancellationToken cancellationToken) => await throughputCollector.TestConnectionSettings(cancellationToken);

[Authorize(Policy = Permissions.ErrorThroughputView)]
[Route("settings/masks")]
[HttpGet]
public async Task<List<string>> GetMasks(CancellationToken cancellationToken)
{
return await throughputCollector.GetReportMasks(cancellationToken);
}

[Authorize(Policy = Permissions.ErrorThroughputManage)]
[Route("settings/masks/update")]
[HttpPost]
public async Task<IActionResult> UpdateMasks(List<string> updateMasks, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,13 @@ public string GenerateToken(
{
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);

// sub + preferred_username are required by PermissionVerbHandler for the audit log;
// defaulting them here keeps callers concise. Callers that need to test the
// missing-claim path pass an explicit additionalClaim with an empty value to override.
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new("preferred_username", subject),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ public OpenIdConnectTestConfiguration WithAuthenticationDisabled()
return this;
}

/// <summary>
/// Enables role-based authorization. When on, controllers carrying
/// <c>[Authorize(Policy = Permissions.X)]</c> require the caller's "roles" claim to map to a
/// role that grants the permission via <c>RolePermissions</c>. When off, the policy provider
/// returns allow-all policies and any authenticated request reaches the controller.
/// </summary>
public OpenIdConnectTestConfiguration WithRoleBasedAuthorizationEnabled()
{
SetEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true");
return this;
}

/// <summary>
/// Disables settings validation. This allows testing with placeholder/fake OIDC settings.
/// Should only be used in test scenarios where a real OIDC provider is not available.
Expand Down Expand Up @@ -164,6 +176,7 @@ public void ClearConfiguration()
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_CLIENTID");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_APISCOPES");
ClearEnvironmentVariable("AUTHENTICATION_SERVICEPULSE_AUTHORITY");
ClearEnvironmentVariable("AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED");
ClearEnvironmentVariable("VALIDATECONFIG");
}

Expand Down
40 changes: 40 additions & 0 deletions src/ServiceControl.AcceptanceTests.RavenDB/StartupModeTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
namespace ServiceControl.AcceptanceTests.RavenDB
{
using System;
using System.Linq;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Hosting.Commands;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NServiceBus;
using NUnit.Framework;
using Particular.ServiceControl.Hosting;
using Persistence;
Expand Down Expand Up @@ -54,5 +57,42 @@ public async Task CanRunMaintenanceMode()
[Test]
public async Task CanRunImportFailedMessagesMode()
=> await new ImportFailedErrorsCommand().Execute(new HostArguments([]), settings);

[Test]
public async Task ImportFailedErrorsHostCanActivateAllMessageHandlers()
{
// The import host consumes the instance's regular input queue, so pending recoverability
// commands (e.g. ArchiveMessage from a bulk archive) can arrive while it runs. Every
// handler of the RecoverabilityComponent the host registers must therefore be activatable.
var handlerTypes = typeof(ImportFailedErrorsCommand).Assembly.GetTypes()
.Where(type => !type.IsAbstract && type.GetInterfaces().Any(
i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IHandleMessages<>)))
.Where(type => type.Namespace!.StartsWith("ServiceControl.Recoverability") ||
type.Namespace.StartsWith("ServiceControl.MessageFailures"))
.OrderBy(type => type.FullName)
.ToArray();

Assert.That(handlerTypes, Is.Not.Empty);

using var host = ImportFailedErrorsCommand.BuildHost(settings);
await host.StartAsync();
try
{
await using var scope = host.Services.CreateAsyncScope();
Assert.Multiple(() =>
{
foreach (var handlerType in handlerTypes)
{
// NServiceBus activates handlers via ActivatorUtilities, resolving constructor
// dependencies from the container without the handler type being registered.
Assert.That(() => ActivatorUtilities.CreateInstance(scope.ServiceProvider, handlerType), Throws.Nothing, handlerType.FullName);
}
});
}
finally
{
await host.StopAsync();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
{
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
Expand Down Expand Up @@ -35,6 +36,7 @@ public void ConfigureAuth()
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithServicePulseClientId(TestClientId)
Expand Down Expand Up @@ -124,7 +126,10 @@ public async Task Should_accept_requests_with_valid_bearer_token()
_ = await Define<Context>()
.Done(async ctx =>
{
var validToken = mockOidcServer.GenerateToken();
// The "reader" role grants every :view permission, including error:messages:view
// required by /api/errors. Without a role-bearing claim the request would be 403.
var validToken = mockOidcServer.GenerateToken(
additionalClaims: new[] { new Claim("roles", "reader") });
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient,
HttpMethod.Get,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect;

using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;
using ServiceControl.Infrastructure.Auth;

/// <summary>
/// my/routes returns the API routes the current token may call, as { method, url_template } entries.
/// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it
/// already calls rather than on the server's internal permission vocabulary.
/// </summary>
class When_my_routes_are_requested : AcceptanceTest
{
OpenIdConnectTestConfiguration configuration;
MockOidcServer mockOidcServer;

const string TestAudience = "api://test-audience";

[SetUp]
public void ConfigureAuth()
{
mockOidcServer = new MockOidcServer(audience: TestAudience);
mockOidcServer.Start();

configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
}

[TearDown]
public void CleanupAuth()
{
configuration?.Dispose();
mockOidcServer?.Dispose();
}

[Test]
public async Task Should_reject_requests_without_bearer_token()
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
HttpClient, HttpMethod.Get, "/api/my/routes");
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertUnauthorized(response);
}

class Context : ScenarioContext;
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async Task InitializeServiceControl(ScenarioContext context)

hostBuilder.Services.AddScenarioContext(context);
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControl(settings, configuration);
hostBuilder.AddServiceControlHttps(settings.HttpsSettings);
hostBuilder.AddServiceControlApi(settings.CorsSettings);
Expand Down
1 change: 1 addition & 0 deletions src/ServiceControl.Api/Contracts/RootUrls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public class RootUrls
public string EventLogItems { get; set; }
public string ArchivedGroupsUrl { get; set; }
public string GetArchiveGroup { get; set; }
public string MyRoutesUrl { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect
{
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
Expand Down Expand Up @@ -32,6 +33,7 @@ public void ConfigureAuth()
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Audit)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
Expand Down Expand Up @@ -92,7 +94,10 @@ public async Task Should_accept_requests_with_valid_bearer_token()
_ = await Define<Context>()
.Done(async ctx =>
{
var validToken = mockOidcServer.GenerateToken();
// The "reader" role grants every :view permission, including audit:message:view
// required by /api/messages. Without a role-bearing claim the request would be 403.
var validToken = mockOidcServer.GenerateToken(
additionalClaims: new[] { new Claim("roles", "reader") });
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient,
HttpMethod.Get,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async Task InitializeServiceControl(ScenarioContext context)
});
hostBuilder.Services.AddScenarioContext(context);
hostBuilder.AddServiceControlAuthentication(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAuthorization(settings.OpenIdConnectSettings);
hostBuilder.AddServiceControlAudit((criticalErrorContext, cancellationToken) =>
{
var logitem = new ScenarioContext.LogItem
Expand Down
Loading
Loading