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