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.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.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 664f56b190..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 @@ -24,4 +26,16 @@ 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 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 }); + + 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 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