From 97f38069f8c7c14022d6eb99f5a421e0b87fcd4a Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Fri, 26 Jun 2026 15:44:02 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Pin=20my/routes=20manifest?= =?UTF-8?q?=20field=20names=20to=20snake=5Fcase=20across=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Primary instance serializes JSON snake_case while the Monitoring instance serializes camelCase, so the same my/routes contract emitted url_template on one and urlTemplate on the other. A client merging both manifests would silently drop every entry from the differently-cased instance. Pin the field names on RouteManifestEntry with [JsonPropertyName] so every host emits an identical { method, url_template } shape regardless of its global JSON naming policy. --- .../OpenIdConnect/When_my_routes_are_requested.cs | 2 +- .../Auth/MyRoutesController.cs | 2 +- .../Auth/RouteManifest.cs | 13 +++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs index 2093227e72..4cea494622 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -13,7 +13,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect; using ServiceControl.Infrastructure.Auth; /// -/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries. +/// my/routes returns the API routes the current token may call, as { method, url_template } 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. /// diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs index 28a6d36ec1..9d7194bb4a 100644 --- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Hosting.Auth; using ServiceControl.Infrastructure.Auth; /// -/// Returns the API routes the current token may call, as { method, urlTemplate } entries. +/// 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 diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs index c697a056f2..d583803f62 100644 --- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; /// @@ -28,8 +29,16 @@ public static string Normalize(string rawTemplate) /// A route the server hosts, with the authorization metadata read from its endpoint. public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous); -/// A single allowed-route entry returned to the client. -public sealed record RouteManifestEntry(string Method, string UrlTemplate); +/// +/// A single allowed-route entry returned to the client. The JSON field names are pinned with +/// so the manifest has one stable shape regardless of each host's +/// global JSON naming policy (the Primary instance serializes snake_case, the Monitoring instance +/// camelCase). Without this the same contract would emit url_template on one instance and +/// urlTemplate on another, and clients that merge both would silently drop half the routes. +/// +public sealed record RouteManifestEntry( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("url_template")] string UrlTemplate); /// /// Projects the route table down to the entries a caller may invoke. A route is included when it is From 245873bf2eeb023d9f29d357a8bac99b82be9823 Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Thu, 2 Jul 2026 13:17:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Guard=20the=20my/routes=20manif?= =?UTF-8?q?est=20snake=5Fcase=20wire=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acceptance test deserializes the response into the C# record, which is casing-agnostic, so it would not catch a regression of the emitted field names. Add a serialization test asserting RouteManifestEntry emits snake_case even under a camelCase policy (as the Monitoring instance uses), pinning the contract that [JsonPropertyName] enforces. --- .../RouteManifestEntrySerializationTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs new file mode 100644 index 0000000000..664f56b190 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Text.Json; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteManifestEntrySerializationTests +{ + // The my/routes manifest must have ONE wire shape across instances. The Primary instance + // serializes snake_case and the Monitoring instance camelCase, so RouteManifestEntry pins its + // field names with [JsonPropertyName]. This test guards that contract: even under a camelCase + // policy (as on the Monitoring host) the emitted names stay snake_case, so a client merging both + // instances never silently drops the differently-cased entries. + [Test] + public void Emits_snake_case_field_names_even_under_a_camelCase_policy() + { + var json = JsonSerializer.Serialize( + new RouteManifestEntry("GET", "/api/errors"), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + Assert.That(json, Does.Contain("\"method\"")); + Assert.That(json, Does.Contain("\"url_template\"")); + Assert.That(json, Does.Not.Contain("urlTemplate")); + } +}