Skip to content
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;
}
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; }
}
}
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;
}
}
Loading