Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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, urlTemplate } 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);
}

[Test]
public async Task Reader_can_view_but_cannot_retry()
{
var routes = await GetRoutes(RolePermissions.Reader);

using (Assert.EnterMultipleScope())
{
Assert.That(routes.Any(r => r.UrlTemplate == "/api/configuration"), Is.True,
"reader holds :view permissions, so view routes are allowed");
Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.False,
"reader has no retry permission, so retry routes are excluded");
}
}

[Test]
public async Task Writer_can_retry()
{
var routes = await GetRoutes(RolePermissions.Writer);

Assert.That(routes.Any(r => r.Method == "POST" && r.UrlTemplate.EndsWith("/retry")), Is.True,
"writer holds every permission, so retry routes are allowed");
}

async Task<List<RouteManifestEntry>> GetRoutes(string role)
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]);
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient, HttpMethod.Get, "/api/my/routes", token);
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertAuthenticated(response);

var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<RouteManifestEntry>>(content, SerializerOptions);
}

class Context : ScenarioContext;
}
46 changes: 45 additions & 1 deletion src/ServiceControl.Audit.UnitTests/API/APIApprovals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
using System.Text;
using Audit.Infrastructure.Settings;
using Audit.Infrastructure.WebApi;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using NUnit.Framework;
using Particular.Approvals;
using ServiceControl.Hosting.Auth;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
class APIApprovals
Expand Down Expand Up @@ -84,7 +87,9 @@ public void HttpApiRoutes()

IEnumerable<(MethodInfo Method, RouteAttribute Route)> GetControllerRoutes()
{
var controllers = typeof(Program).Assembly.GetTypes()
var controllers = GetControllerAssemblies()
.SelectMany(a => a.GetTypes())
.Distinct()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t));

foreach (var type in controllers)
Expand All @@ -101,6 +106,45 @@ public void HttpApiRoutes()
}
}

static IEnumerable<Assembly> GetControllerAssemblies() =>
[
typeof(Program).Assembly,
typeof(MyRoutesController).Assembly
];

[Test]
public void Authorize_policies_are_known_permissions()
{
var controllers = GetControllerAssemblies()
.SelectMany(a => a.GetTypes())
.Distinct()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t));

foreach (var type in controllers)
{
foreach (var att in type.GetCustomAttributes<AuthorizeAttribute>())
{
if (!string.IsNullOrEmpty(att.Policy))
{
Assert.That(Permissions.All.Contains(att.Policy), Is.True,
$"Controller {type.FullName} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All.");
}
}

foreach (var method in type.GetMethods())
{
foreach (var att in method.GetCustomAttributes<AuthorizeAttribute>())
{
if (!string.IsNullOrEmpty(att.Policy))
{
Assert.That(Permissions.All.Contains(att.Policy), Is.True,
$"Method {type.FullName}:{method.Name} has [Authorize(Policy = \"{att.Policy}\")] which is not a known permission in Permissions.All.");
}
}
}
}
}

static string PrettyTypeName(Type t)
{
if (t.IsArray)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ GET /messages/{id}/body => ServiceControl.Audit.Auditing.MessagesView.GetMessage
GET /messages/search => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q, CancellationToken cancellationToken)
GET /messages/search/{keyword} => ServiceControl.Audit.Auditing.MessagesView.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword, CancellationToken cancellationToken)
GET /messages2 => ServiceControl.Audit.Auditing.MessagesView.GetMessages2Controller:GetAllMessages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q, CancellationToken cancellationToken)
GET /my/routes => ServiceControl.Hosting.Auth.MyRoutesController:GetMyRoutes()
GET /sagas/{id} => ServiceControl.Audit.SagaAudit.SagasController:Sagas(PagingInfo pagingInfo, Guid id, CancellationToken cancellationToken)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static void AddServiceControlAuditApi(this IHostApplicationBuilder builde
options.ModelBinderProviders.Insert(0, new SortInfoModelBindingProvider());
});
controllers.AddApplicationPart(Assembly.GetExecutingAssembly());
controllers.AddApplicationPart(typeof(ServiceControl.Hosting.Auth.MyRoutesController).Assembly);
controllers.AddJsonOptions(options => options.JsonSerializerOptions.CustomizeDefaults());
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/ServiceControl.Hosting/Auth/ClaimsPrinicpalExtensionMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace ServiceControl.Hosting.Auth
{
using System;
using System.Security.Claims;

public static class ClaimsPrinicpalExtensionMethods
{
public static string RequireClaim(this ClaimsPrincipal user, string claimType, string settingName)
{
var value = user.FindFirst(claimType)?.Value;
if (string.IsNullOrEmpty(value))
{
throw new InvalidOperationException(
$"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " +
"Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits.");
}
return value;
}
}
}
29 changes: 29 additions & 0 deletions src/ServiceControl.Hosting/Auth/MyRoutesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ServiceControl.Infrastructure;
using ServiceControl.Infrastructure.Auth;

/// <summary>
/// Returns the API routes the current token may call, as <c>{ method, urlTemplate }</c> entries.
/// This is the per-instance authorization contract for clients (ServicePulse): each instance reports
/// only the routes it serves, so a client matches its outgoing request against the allowed set without
/// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that
/// contract, so it is reachable by any authenticated user ([Authorize], no specific permission).
/// </summary>
[ApiController]
[Route("api")]
[Authorize]
public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConnectSettings settings) : ControllerBase
{
[HttpGet]
[Route("my/routes")]
public ActionResult<IReadOnlyList<RouteManifestEntry>> GetMyRoutes()
{
var effective = EffectivePermissions.ForUser(User, settings);
return Ok(RouteManifestFilter.Filter(table.Entries, effective));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,9 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h
// injected OpenIdConnectSettings so the handler can match them on the principal.
services.AddSingleton<IAuthorizationAuditLog, AuthorizationAuditLog>();
services.AddSingleton<IAuthorizationHandler, PermissionVerbHandler>();

// Backs the my/routes manifest: a singleton table projected from the wired endpoints. Reuses
// the EndpointDataSource the framework registers, so it sees exactly the routes that are served.
services.AddSingleton<RouteAuthorizationTable>();
}
}
18 changes: 3 additions & 15 deletions src/ServiceControl.Hosting/Auth/PermissionVerbHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ protected override Task HandleRequirementAsync(
return Task.CompletedTask;
}

var subjectId = RequireClaim(context.User, oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim");
var subjectName = RequireClaim(context.User, oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim");
var subjectId = context.User.RequireClaim(oidcSettings.SubjectIdClaim, "Authentication.SubjectIdClaim");
var subjectName = context.User.RequireClaim(oidcSettings.SubjectNameClaim, "Authentication.SubjectNameClaim");
var roles = context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToArray();
var permission = requirement.Permission;

Expand Down Expand Up @@ -71,16 +71,4 @@ protected override Task HandleRequirementAsync(
// Leave the requirement unmet → the framework forbids (403).
return Task.CompletedTask;
}

static string RequireClaim(ClaimsPrincipal user, string claimType, string settingName)
{
var value = user.FindFirst(claimType)?.Value;
if (string.IsNullOrEmpty(value))
{
throw new InvalidOperationException(
$"Authenticated principal is missing the required '{claimType}' claim configured by {settingName}. " +
"Configure the identity provider to emit this claim, or point the setting at the claim the IdP actually emits.");
}
return value;
}
}
}
57 changes: 57 additions & 0 deletions src/ServiceControl.Hosting/Auth/RouteAuthorizationTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#nullable enable
namespace ServiceControl.Hosting.Auth;

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using ServiceControl.Infrastructure.Auth;

/// <summary>
/// Projects the wired controller endpoints into the static <c>route ⇒ permission</c> table that backs
/// the <c>my/routes</c> manifest. Built once on first access (after endpoints are mapped) and cached
/// for the process lifetime — routes are compiled in and never change at runtime. Each endpoint
/// contributes one <see cref="RouteAuthInfo"/> per HTTP method, carrying the policy name from its
/// <c>[Authorize(Policy = …)]</c> attribute (the permission), whether it is <c>[AllowAnonymous]</c>,
/// and the normalized template. No-policy endpoints are authenticated-only, matching the
/// RequireAuthenticatedUser fallback policy.
/// </summary>
public sealed class RouteAuthorizationTable(EndpointDataSource endpointDataSource)
{
readonly Lazy<IReadOnlyList<RouteAuthInfo>> entries = new(() => Build(endpointDataSource));

public IReadOnlyList<RouteAuthInfo> Entries => entries.Value;

static IReadOnlyList<RouteAuthInfo> Build(EndpointDataSource endpointDataSource)
{
var result = new List<RouteAuthInfo>();

foreach (var endpoint in endpointDataSource.Endpoints.OfType<RouteEndpoint>())
{
// Only controller actions: skips the SignalR hub and other non-MVC endpoints.
if (endpoint.Metadata.GetMetadata<ControllerActionDescriptor>() is null)
{
continue;
}

var template = RouteTemplateNormalizer.Normalize(endpoint.RoutePattern.RawText ?? string.Empty);
var allowAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
var requiredPermission = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(authorize => authorize.Policy)
.FirstOrDefault(policy => !string.IsNullOrEmpty(policy));

var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods
?? [];

foreach (var method in methods)
{
result.Add(new RouteAuthInfo(method, template, requiredPermission, allowAnonymous));
}
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System;
using System.Linq;
using System.Security.Claims;
using NUnit.Framework;
using ServiceControl.Configuration;
using ServiceControl.Infrastructure;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
class EffectivePermissionsTests
{
static readonly SettingsRootNamespace TestNamespace = new("ServiceControl");

static ClaimsPrincipal PrincipalWithRoles(params string[] roles) =>
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "test"));

[TearDown]
public void TearDown()
{
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", null);
}

[Test]
public void Rbac_enabled_returns_the_union_of_role_permissions()
{
Environment.SetEnvironmentVariable("SERVICECONTROL_AUTHENTICATION_ROLEBASEDAUTHORIZATIONENABLED", "true");
var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);

var result = EffectivePermissions.ForUser(PrincipalWithRoles(RolePermissions.Reader), settings);

Assert.That(result, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader)));
}

[Test]
public void Rbac_disabled_returns_all_permissions()
{
var settings = new OpenIdConnectSettings(TestNamespace, validateConfiguration: false);

var result = EffectivePermissions.ForUser(PrincipalWithRoles("anything"), settings);

Assert.That(result, Is.EquivalentTo(Permissions.All));
}
}
Loading
Loading