From e717fbb025a47f8608745294a0d5aac6e4cac6ae Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 3 Jul 2026 10:12:28 +0200 Subject: [PATCH 1/2] Include user rols in routes manifest --- .../Auth/MyRoutesController.cs | 19 +++++++++++-------- .../RouteManifestEntrySerializationTests.cs | 14 ++++++++++++++ .../Auth/RouteManifest.cs | 10 ++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs index 9d7194bb4a..d01d8eae88 100644 --- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -1,18 +1,20 @@ #nullable enable namespace ServiceControl.Hosting.Auth; -using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ServiceControl.Infrastructure; using ServiceControl.Infrastructure.Auth; /// -/// Returns the API routes the current token may call, as { method, url_template } 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). +/// Returns the caller's role claims and the API routes the current token may call, as +/// { method, url_template } 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). /// [ApiController] [Route("api")] @@ -21,9 +23,10 @@ public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConn { [HttpGet] [Route("my/routes")] - public ActionResult> GetMyRoutes() + public ActionResult GetMyRoutes() { var effective = EffectivePermissions.ForUser(User, settings); - return Ok(RouteManifestFilter.Filter(table.Entries, effective)); + var roles = User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).Distinct().ToArray(); + return Ok(new MyRoutesResponse(roles, RouteManifestFilter.Filter(table.Entries, effective))); } } diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs index 664f56b190..ed655350d4 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -24,4 +24,18 @@ public void Emits_snake_case_field_names_even_under_a_camelCase_policy() Assert.That(json, Does.Contain("\"url_template\"")); Assert.That(json, Does.Not.Contain("urlTemplate")); } + + // Same contract as above, but for the response wrapper: roles are reported once at the top level, + // not duplicated onto every route entry. + [Test] + public void Response_wraps_roles_and_routes_under_pinned_field_names() + { + var json = JsonSerializer.Serialize( + new MyRoutesResponse(["admin"], [new RouteManifestEntry("GET", "/api/errors")]), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + Assert.That(json, Does.Contain("\"roles\"")); + Assert.That(json, Does.Contain("\"routes\"")); + Assert.That(json, Does.Contain("\"url_template\"")); + } } diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs index d583803f62..6622b14cde 100644 --- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -40,6 +40,16 @@ public sealed record RouteManifestEntry( [property: JsonPropertyName("method")] string Method, [property: JsonPropertyName("url_template")] string UrlTemplate); +/// +/// The full my/routes payload: the caller's role claims alongside the routes they may invoke. Roles +/// are reported once at the top level rather than repeated per entry, since they describe the caller, +/// not the individual route. Field names are pinned for the same cross-instance reason as +/// . +/// +public sealed record MyRoutesResponse( + [property: JsonPropertyName("roles")] IReadOnlyList Roles, + [property: JsonPropertyName("routes")] IReadOnlyList Routes); + /// /// Projects the route table down to the entries a caller may invoke. A route is included when it is /// anonymous, requires only authentication (no specific permission), or its required permission is in From 26475574bcdb6da37cf1c0010d596cf8b8b81853 Mon Sep 17 00:00:00 2001 From: williambza Date: Fri, 3 Jul 2026 11:37:06 +0200 Subject: [PATCH 2/2] Use verify instead --- src/Directory.Packages.props | 1 + ...s_and_routes_under_pinned_field_names.verified.txt | 11 +++++++++++ .../Auth/RouteManifestEntrySerializationTests.cs | 8 ++++---- .../ServiceControl.Infrastructure.Tests.csproj | 1 + 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1932243700..a704e57918 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -82,6 +82,7 @@ + diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt new file mode 100644 index 0000000000..ed2dd257ef --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.Response_wraps_roles_and_routes_under_pinned_field_names.verified.txt @@ -0,0 +1,11 @@ +{ + roles: [ + admin + ], + routes: [ + { + method: GET, + url_template: /api/errors + } + ] +} \ No newline at end of file diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs index ed655350d4..3d72610362 100644 --- a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -2,8 +2,10 @@ namespace ServiceControl.Infrastructure.Tests.Auth; using System.Text.Json; +using System.Threading.Tasks; using NUnit.Framework; using ServiceControl.Infrastructure.Auth; +using VerifyNUnit; [TestFixture] class RouteManifestEntrySerializationTests @@ -28,14 +30,12 @@ public void Emits_snake_case_field_names_even_under_a_camelCase_policy() // Same contract as above, but for the response wrapper: roles are reported once at the top level, // not duplicated onto every route entry. [Test] - public void Response_wraps_roles_and_routes_under_pinned_field_names() + public Task Response_wraps_roles_and_routes_under_pinned_field_names() { var json = JsonSerializer.Serialize( new MyRoutesResponse(["admin"], [new RouteManifestEntry("GET", "/api/errors")]), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - Assert.That(json, Does.Contain("\"roles\"")); - Assert.That(json, Does.Contain("\"routes\"")); - Assert.That(json, Does.Contain("\"url_template\"")); + return Verifier.VerifyJson(json); } } diff --git a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj index 4cddac2b26..11d4e96b5a 100644 --- a/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj +++ b/src/ServiceControl.Infrastructure.Tests/ServiceControl.Infrastructure.Tests.csproj @@ -15,6 +15,7 @@ + \ No newline at end of file