Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.9" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.9" />
<PackageVersion Include="Validar.Fody" Version="1.9.0" />
<PackageVersion Include="Verify.NUnit" Version="31.20.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup Label="Versions to pin transitive references">
Expand Down
19 changes: 11 additions & 8 deletions src/ServiceControl.Hosting/Auth/MyRoutesController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Returns the API routes the current token may call, as <c>{ method, url_template }</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).
/// Returns the caller's role claims and the API routes the current token may call, as
/// <c>{ method, url_template }</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")]
Expand All @@ -21,9 +23,10 @@ public sealed class MyRoutesController(RouteAuthorizationTable table, OpenIdConn
{
[HttpGet]
[Route("my/routes")]
public ActionResult<IReadOnlyList<RouteManifestEntry>> GetMyRoutes()
public ActionResult<MyRoutesResponse> 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)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
roles: [
admin
],
routes: [
{
method: GET,
url_template: /api/errors
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]

@ramonsmits ramonsmits Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I see this I just want to use https://github.com/VerifyTests/Verify from @SimonCropp

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed it to use verify

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey guys. probs a good idea do the a run through on the wizard to see the other recommended settings https://github.com/VerifyTests/Verify/blob/main/docs/wiz/readme.md

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Verify.NUnit" />
</ItemGroup>

</Project>
10 changes: 10 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/RouteManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ public sealed record RouteManifestEntry(
[property: JsonPropertyName("method")] string Method,
[property: JsonPropertyName("url_template")] string UrlTemplate);

/// <summary>
/// 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
/// <see cref="RouteManifestEntry"/>.
/// </summary>
public sealed record MyRoutesResponse(
[property: JsonPropertyName("roles")] IReadOnlyList<string> Roles,
[property: JsonPropertyName("routes")] IReadOnlyList<RouteManifestEntry> Routes);

/// <summary>
/// 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
Expand Down
Loading