Skip to content

Commit ff995c9

Browse files
ramonsmitsdvdstelt
andauthored
🚑️ Require an authenticated user on permission policies when RBAC is disabled (#5979)
PermissionPolicyProvider chose between the role-checking policy and an allow-all policy by looking only at Authentication.RoleBasedAuthorizationEnabled. With authentication enabled and RBAC at its default of false, every permission resolved to allow-all. Every controller action carries a permission policy, so the RequireAuthenticatedUser fallback policy never applied. Requests without a token, or with an invalid or expired token, reached the controllers on the error, audit and monitoring instances. The provider now uses both settings: - Authentication disabled: allow-all. RBAC is ignored. - Authentication enabled, RBAC disabled: every permission requires an authenticated user. This is the behavior from before role-based access control was added. - Authentication and RBAC enabled: unchanged. Other changes: - The startup log line for the authentication settings includes the RBAC setting. - The monitoring container health check calls the anonymous root endpoint instead of /connection. /connection requires an authenticated user when authentication is enabled. Tests: - Unit tests evaluate every permission through the real registrations, for each combination of the two settings. - New When_authentication_is_enabled_without_role_based_authorization acceptance fixtures on all three instances. When RBAC was added, the existing fixtures were switched to RBAC enabled only, which removed the coverage for the default configuration. - A request without a token to every route that is not [AllowAnonymous] gets 401. This test fails without the fix. - A valid token without roles is accepted. - The existing When_authentication_is_enabled fixtures (RBAC enabled) also run the every-route test. Co-authored-by: Dennis van der Stelt <dvdstelt@gmail.com>
1 parent 5e2364f commit ff995c9

21 files changed

Lines changed: 622 additions & 32 deletions

‎src/ServiceControl.AcceptanceTesting/OpenIdConnect/OpenIdConnectTestConfiguration.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class OpenIdConnectTestConfiguration(ServiceControlInstanceType instanceT
2121
/// </summary>
2222
public OpenIdConnectTestConfiguration WithAuthenticationEnabled()
2323
{
24-
SetEnvironmentVariable("AUTHENTICATION_ENABLED", "true");
24+
SetEnvironmentVariable("AUTHENTICATION_ENABLED", bool.TrueString);
2525
return this;
2626
}
2727

@@ -30,19 +30,19 @@ public OpenIdConnectTestConfiguration WithAuthenticationEnabled()
3030
/// </summary>
3131
public OpenIdConnectTestConfiguration WithAuthenticationDisabled()
3232
{
33-
SetEnvironmentVariable("AUTHENTICATION_ENABLED", "false");
33+
SetEnvironmentVariable("AUTHENTICATION_ENABLED", bool.FalseString);
3434
return this;
3535
}
3636

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

@@ -52,7 +52,7 @@ public OpenIdConnectTestConfiguration WithRoleBasedAuthorizationEnabled()
5252
/// </summary>
5353
public OpenIdConnectTestConfiguration WithConfigurationValidationDisabled()
5454
{
55-
SetEnvironmentVariable("VALIDATECONFIG", "false");
55+
SetEnvironmentVariable("VALIDATECONFIG", bool.FalseString);
5656
return this;
5757
}
5858

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
namespace ServiceControl.AcceptanceTesting.OpenIdConnect;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Authorization;
11+
using Microsoft.AspNetCore.Http;
12+
using Microsoft.AspNetCore.Routing;
13+
using Microsoft.AspNetCore.Routing.Patterns;
14+
15+
/// <summary>
16+
/// Finds every route of a running instance that is not marked <c>[AllowAnonymous]</c>, and sends each
17+
/// one a request without a token. This covers every endpoint, so a new or changed authorization
18+
/// policy cannot let anonymous callers in without a test failing.
19+
/// </summary>
20+
public static class ProtectedRoutes
21+
{
22+
/// <summary>
23+
/// Sends a request without a token to every protected route, and returns each route that did not
24+
/// answer 401, as "METHOD /path → status".
25+
/// </summary>
26+
public static async Task<IReadOnlyList<string>> FindRoutesNotRejectingAnonymousRequests(
27+
HttpClient client,
28+
EndpointDataSource endpointDataSource,
29+
CancellationToken cancellationToken = default)
30+
{
31+
var routes = endpointDataSource.Endpoints
32+
.OfType<RouteEndpoint>()
33+
.Where(endpoint => endpoint.Metadata.GetMetadata<IAllowAnonymous>() is null)
34+
.SelectMany(endpoint => (endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? [HttpMethods.Get])
35+
.Select(method => (Method: method, Path: BuildPath(endpoint.RoutePattern))))
36+
.Distinct()
37+
.ToList();
38+
39+
if (routes.Count == 0)
40+
{
41+
throw new InvalidOperationException("No protected routes found. The endpoint data source is empty or every route allows anonymous access.");
42+
}
43+
44+
var notRejected = new List<string>();
45+
46+
foreach (var (method, path) in routes)
47+
{
48+
using var response = await OpenIdConnectAssertions.SendRequestWithoutAuth(client, new HttpMethod(method), path, cancellationToken);
49+
50+
if (response.StatusCode != HttpStatusCode.Unauthorized)
51+
{
52+
notRejected.Add($"{method} {path} → {(int)response.StatusCode}");
53+
}
54+
}
55+
56+
return notRejected;
57+
}
58+
59+
// Fills each route parameter with a value that satisfies its constraint, so the request matches the
60+
// route and reaches the authorization middleware instead of failing with 404.
61+
static string BuildPath(RoutePattern pattern)
62+
{
63+
var segments = pattern.PathSegments.Select(segment => string.Concat(segment.Parts.Select(part => part switch
64+
{
65+
RoutePatternLiteralPart literal => literal.Content,
66+
RoutePatternSeparatorPart separator => separator.Content,
67+
RoutePatternParameterPart parameter => SampleValue(parameter),
68+
_ => throw new NotSupportedException($"Unsupported route pattern part: {part.GetType().Name}")
69+
})));
70+
71+
return "/" + string.Join('/', segments);
72+
}
73+
74+
static string SampleValue(RoutePatternParameterPart parameter)
75+
{
76+
var constraints = parameter.ParameterPolicies.Select(policy => policy.Content ?? string.Empty).ToArray();
77+
78+
if (constraints.Contains("guid", StringComparer.OrdinalIgnoreCase))
79+
{
80+
return Guid.Empty.ToString();
81+
}
82+
83+
if (constraints.Any(constraint => constraint is "int" or "long"))
84+
{
85+
return "1";
86+
}
87+
88+
if (constraints.Contains("bool", StringComparer.OrdinalIgnoreCase))
89+
{
90+
return bool.TrueString;
91+
}
92+
93+
return "x";
94+
}
95+
}

‎src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
22
{
3+
using System.Collections.Generic;
34
using System.Net.Http;
45
using System.Security.Claims;
56
using System.Threading.Tasks;
67
using AcceptanceTesting;
78
using AcceptanceTesting.OpenIdConnect;
9+
using Microsoft.AspNetCore.Routing;
10+
using Microsoft.Extensions.DependencyInjection;
811
using NServiceBus.AcceptanceTesting;
912
using NUnit.Framework;
1013

@@ -235,6 +238,24 @@ public async Task Should_forbid_authenticated_user_lacking_required_permission()
235238
OpenIdConnectAssertions.AssertForbidden(response);
236239
}
237240

241+
[Test]
242+
public async Task Should_reject_anonymous_requests_on_every_protected_route()
243+
{
244+
IReadOnlyList<string> notRejected = null;
245+
246+
_ = await Define<Context>()
247+
.Done(async ctx =>
248+
{
249+
notRejected = await ProtectedRoutes.FindRoutesNotRejectingAnonymousRequests(
250+
HttpClient,
251+
ServiceProvider.GetRequiredService<EndpointDataSource>());
252+
return true;
253+
})
254+
.Run();
255+
256+
Assert.That(notRejected, Is.Empty, "Protected routes that did not answer 401 to a request without a token");
257+
}
258+
238259
class Context : ScenarioContext;
239260
}
240261
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect
2+
{
3+
using System.Collections.Generic;
4+
using System.Net.Http;
5+
using System.Threading.Tasks;
6+
using AcceptanceTesting;
7+
using AcceptanceTesting.OpenIdConnect;
8+
using Microsoft.AspNetCore.Routing;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using NServiceBus.AcceptanceTesting;
11+
using NUnit.Framework;
12+
13+
/// <summary>
14+
/// Authentication enabled, role-based authorization disabled (the default). Every route that is not
15+
/// [AllowAnonymous] must still require a valid token. Roles are not checked, so a valid token without
16+
/// roles is accepted.
17+
/// </summary>
18+
[NonParallelizable]
19+
class When_authentication_is_enabled_without_role_based_authorization : AcceptanceTest
20+
{
21+
OpenIdConnectTestConfiguration configuration;
22+
MockOidcServer mockOidcServer;
23+
24+
const string TestAudience = "api://test-audience";
25+
26+
[SetUp]
27+
public void ConfigureAuth()
28+
{
29+
mockOidcServer = new MockOidcServer(audience: TestAudience);
30+
mockOidcServer.Start();
31+
32+
configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
33+
.WithConfigurationValidationDisabled()
34+
.WithAuthenticationEnabled()
35+
.WithAuthority(mockOidcServer.Authority)
36+
.WithAudience(TestAudience)
37+
.WithServicePulseClientId("test-client-id")
38+
.WithServicePulseApiScopes("[\"api://test-audience/.default\"]")
39+
.WithRequireHttpsMetadata(false);
40+
}
41+
42+
[TearDown]
43+
public void CleanupAuth()
44+
{
45+
configuration?.Dispose();
46+
mockOidcServer?.Dispose();
47+
}
48+
49+
[Test]
50+
public async Task Should_reject_anonymous_requests_on_every_protected_route()
51+
{
52+
IReadOnlyList<string> notRejected = null;
53+
54+
_ = await Define<Context>()
55+
.Done(async ctx =>
56+
{
57+
notRejected = await ProtectedRoutes.FindRoutesNotRejectingAnonymousRequests(
58+
HttpClient,
59+
ServiceProvider.GetRequiredService<EndpointDataSource>());
60+
return true;
61+
})
62+
.Run();
63+
64+
Assert.That(notRejected, Is.Empty, "Protected routes that did not answer 401 to a request without a token");
65+
}
66+
67+
[Test]
68+
public async Task Should_accept_token_without_roles()
69+
{
70+
HttpResponseMessage response = null;
71+
72+
_ = await Define<Context>()
73+
.Done(async ctx =>
74+
{
75+
var tokenWithoutRoles = mockOidcServer.GenerateToken();
76+
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
77+
HttpClient,
78+
HttpMethod.Get,
79+
"/api/errors",
80+
tokenWithoutRoles);
81+
return response != null;
82+
})
83+
.Run();
84+
85+
OpenIdConnectAssertions.AssertAuthenticated(response);
86+
}
87+
88+
class Context : ScenarioContext;
89+
}
90+
}

‎src/ServiceControl.AcceptanceTests/TestSupport/AcceptanceTest.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ abstract class AcceptanceTest : NServiceBusAcceptanceTest, IAcceptanceTestInfras
2323
{
2424
public IDomainEvents DomainEvents => serviceControlRunnerBehavior.DomainEvents;
2525
public HttpClient HttpClient => serviceControlRunnerBehavior.HttpClient;
26+
protected IServiceProvider ServiceProvider => serviceControlRunnerBehavior.ServiceProvider;
2627
public JsonSerializerOptions SerializerOptions => serviceControlRunnerBehavior.SerializerOptions;
2728
public Settings Settings => serviceControlRunnerBehavior.Settings;
2829

‎src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentBehavior.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public ServiceControlComponentBehavior(ITransportIntegration transportToUse, IAc
2424
}
2525

2626
public HttpClient HttpClient => runner.HttpClient;
27+
public IServiceProvider ServiceProvider => runner.ServiceProvider;
2728
public JsonSerializerOptions SerializerOptions => runner.SerializerOptions;
2829
public Settings Settings => runner.Settings;
2930
public IDomainEvents DomainEvents => runner.DomainEvents;

‎src/ServiceControl.AcceptanceTests/TestSupport/ServiceControlComponentRunner.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public ServiceControlComponentRunner(ITransportIntegration transportToUse, IAcce
4444
public override string Name { get; } = $"{nameof(ServiceControlComponentRunner)}";
4545
public Settings Settings { get; private set; }
4646
public HttpClient HttpClient { get; private set; }
47+
public IServiceProvider ServiceProvider { get; private set; }
4748
public JsonSerializerOptions SerializerOptions => Infrastructure.WebApi.SerializerOptions.Default;
4849
public IDomainEvents DomainEvents { get; private set; }
4950

@@ -152,6 +153,8 @@ async Task InitializeServiceControlCore(ScenarioContext context)
152153
await host.StartAsync();
153154
DomainEvents = host.Services.GetRequiredService<IDomainEvents>();
154155
// Bring this back and look into the base address of the client
156+
ServiceProvider = host.Services;
157+
155158
HttpClient = host.GetTestServer().CreateClient();
156159
}
157160
}

‎src/ServiceControl.Audit.AcceptanceTests/Security/OpenIdConnect/When_authentication_is_enabled.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
namespace ServiceControl.Audit.AcceptanceTests.Security.OpenIdConnect
22
{
3+
using System.Collections.Generic;
34
using System.Net.Http;
45
using System.Security.Claims;
56
using System.Threading.Tasks;
67
using AcceptanceTesting;
78
using AcceptanceTesting.OpenIdConnect;
9+
using Microsoft.AspNetCore.Routing;
10+
using Microsoft.Extensions.DependencyInjection;
811
using NServiceBus.AcceptanceTesting;
912
using NUnit.Framework;
1013

@@ -174,6 +177,24 @@ public async Task Should_reject_requests_with_wrong_issuer()
174177
OpenIdConnectAssertions.AssertUnauthorized(response);
175178
}
176179

180+
[Test]
181+
public async Task Should_reject_anonymous_requests_on_every_protected_route()
182+
{
183+
IReadOnlyList<string> notRejected = null;
184+
185+
_ = await Define<Context>()
186+
.Done(async ctx =>
187+
{
188+
notRejected = await ProtectedRoutes.FindRoutesNotRejectingAnonymousRequests(
189+
HttpClient,
190+
ServiceProvider.GetRequiredService<EndpointDataSource>());
191+
return true;
192+
})
193+
.Run();
194+
195+
Assert.That(notRejected, Is.Empty, "Protected routes that did not answer 401 to a request without a token");
196+
}
197+
177198
[Test]
178199
public async Task Should_allow_anonymous_access_to_root_endpoint()
179200
{

0 commit comments

Comments
 (0)