The Claims Map That Does Not Exist
The README and the sample both configure an auth claims alias map, the configuration class has no such property, and the binder's ignore-unknown-keys behaviour is what makes the omission silent.
Configuration binders are forgiving by design, and that forgiveness is a security property with the sign flipped. Microsoft.Extensions.Configuration's Bind walks the configuration tree, sets every property it can find a match for, and ignores every key it cannot. That is what makes forward-compatible config files possible; it is also what makes a typo, a rename, or a documented-but-unimplemented key indistinguishable from a correctly applied setting. Nothing is logged. Nothing throws. Your YAML looks right and does nothing.
Part 8 ended on a 403 that the gateway cannot explain. This part is about the fix the documentation offers for it.
The key that appears in three places and binds in none
Here is the top of samples/Ntrada.Samples.Api/ntrada.yml, and character-for-character the same block in the README's advanced configuration section:
auth:
enabled: true
global: false
claims:
role: http://schemas.microsoft.com/ws/2008/06/identity/claims/role
— samples/Ntrada.Samples.Api/ntrada.yml:1-5 and README.md:103-107
It reads as an alias map: when a route says claims: {role: admin}, translate the short name role into the full claim type URI before checking the principal. That is exactly the right feature to have, and exactly the right place to put it.
Now the class it binds to:
public class Auth
{
public bool Enabled { get; set; }
public bool Global { get; set; }
public IDictionary<string, Policy> Policies { get; set; }
}
— Configuration/Auth.cs:5-10
Three properties. There is no Claims. There has never been a Claims; the git history contains no commit that added and removed one. The key is present in the only two configuration files the project ships, it is present in the only documentation the project has, and it binds to nothing.
Grep the source for the alias behaviour and there is nothing to find. AuthorizationManager does exactly one thing with claims:
private static bool HasClaims(ClaimsPrincipal user, IDictionary<string, string> claims)
=> claims is null || claims.All(claim => user.HasClaim(claim.Key, claim.Value));
— AuthorizationManager.cs:37-38
ClaimsPrincipal.HasClaim(type, value) is an exact, ordinal match on the claim type string. No aliasing, no normalisation, no short-name resolution.
Why the absence matters more than it sounds
If you are issuing your own tokens with your own claim types, none of this bites. claims: {role: admin} matches a JWT carrying a claim literally typed role, and you never notice.
But the estate this gateway was built for issues tokens with ASP.NET Core's own JwtSecurityTokenHandler, and that handler applies InboundClaimTypeMap by default. The compact role claim in the token becomes a claim typed http://schemas.microsoft.com/ws/2008/06/identity/claims/role on the ClaimsPrincipal. Likewise sub becomes http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier.
So the obvious configuration —
- upstream: /orders
method: DELETE
auth: true
use: downstream
downstream: orders-service/orders
claims:
role: admin
— issues user.HasClaim("role", "admin") against a principal whose role claim is typed with the full URI. It returns false. The route 403s for every user, including users who genuinely hold the role, and the log line names the route and the username and never the claim.
The workaround exists and it is the one the sample is gesturing at: write the full URI in the route's claims: block instead of the short name. That works, it is ugly, and it is why an alias map is worth having. The problem is that the sample shows the alias map instead — so a user hitting the 403 reads the documentation, finds the feature that would fix it, copies it into their config, gets no change in behaviour, and now has two mysteries instead of one. The documented workaround for the estate's most likely auth failure is itself vapour, and the binder's ignore-unknown-keys behaviour is what makes it silent.
auth.claims is not alone in that block, either. useErrorHandler: true and useJaeger: true sit at ntrada.yml:12-13 and README.md:114-115 with no corresponding property on NtradaOptions; both are fossils from before the extension system existed, when those were core flags. The extensions are now enabled by the presence of their key under extensions:, which the same file also does correctly. Three keys in one document that look like configuration and are comments.
What the binder could have said
There is a cheap fix and an expensive one, and the cheap one is very cheap. IConfiguration.Bind has an overload that takes options:
_configuration.GetSection(sectionName).Bind(options, o => o.ErrorOnUnknownConfiguration = true);
That is one lambda in OptionsProvider.GetOptions, and it converts every one of these into a boot-time exception naming the offending key. It would have caught auth.claims, useErrorHandler and useJaeger on the first run after they went stale.
It is fair to note when it arrived: ErrorOnUnknownConfiguration shipped in .NET 5, a year after the last substantive commit here. In netcoreapp3.1 the equivalent was to enumerate GetSection(...).GetChildren() and compare against the bound type's properties by hand — about fifteen lines, and nobody wrote them, in any library, in 2019. Strongly-typed options validation as a first-class idea did not really land until ValidateOnStart in .NET 6. This is a defect of its era rather than of its author, and the honest lesson is not “he should have validated” but “a product whose interface is a document needs a schema for that document, and in 2019 nothing in the .NET ecosystem gave you one for free.”
Claims and policies do the same job twice
There is a design question hiding underneath the missing map, and it is the reason the alias would have been so useful. A route can constrain access two ways:
- upstream: /orders
method: DELETE
auth: true
use: downstream
downstream: orders-service/orders
claims:
role: admin # inline, per route
policies:
- admin # named, defined once under auth.policies
and both resolve to the same primitive:
return HasPolicies(user, routeConfig.Route.Policies) && HasClaims(user, routeConfig.Route.Claims);
— AuthorizationManager.cs:28
A policy is a named claim set — PolicyManager loads auth.policies.<name>.claims into a dictionary and HasPolicy just calls HasClaims with it. So policies: is claims: with a level of indirection, and the two are ANDed together with no way to express OR.
That is a reasonable minimum and a real ceiling. There is no “any of these roles”, no numeric or temporal comparison, no scope-prefix matching — the entire authorisation language is the principal must hold every one of these exact type-value pairs. ASP.NET Core's own AuthorizationPolicyBuilder, with requirements and handlers, was sitting in the framework the gateway already depends on, and IAuthorizationService.AuthorizeAsync(user, policyName) would have given route authors the whole thing for roughly the same amount of code. Choosing a simpler model was defensible for a config-first product — but the simpler model is exactly the one where an exact string match on claim type becomes load-bearing, which is exactly why the missing alias map matters so much.
Two sentinels with the same value
While we are in the authorisation path, there is a second, more consequential ambiguity worth naming, because it is the same kind of mistake at a different level.
public IDictionary<string, string> GetClaims(string policy)
=> _policies.TryGetValue(policy, out var claims) ? claims : null;
— PolicyManager.cs:20-21
An unknown policy name returns null. And HasClaims, three lines up from where we started, reads claims is null as “this route imposes no claim constraints” and returns true.
The sentinel for this policy does not exist and the sentinel for this policy constrains nothing are the same value. A route declaring policies: [admin] against a config with no admin policy authorises every authenticated caller. Not a 500, not a 403 — a 200.
Except it cannot happen, and the reason it cannot is the best fifteen lines in the codebase:
private void VerifyPolicies(IDictionary<string, Dictionary<string, string>> policies)
{
var definedPolicies = (_options.Modules ?? new Dictionary<string, Module>())
.Select(m => m.Value)
.SelectMany(m => m.Routes ?? Enumerable.Empty<Route>())
.SelectMany(r => r.Policies ?? Enumerable.Empty<string>())
.Distinct();
var missingPolicies = definedPolicies
.Except(policies.Select(p => p.Key))
.ToArray();
if (missingPolicies.Any())
{
throw new InvalidOperationException($"Missing policies: '{string.Join(", ", missingPolicies)}'");
}
}
— PolicyManager.cs:32-46
Called from the constructor. Every policy name referenced by any route is checked against the policy table at boot, and a mismatch fails the process with a message that names the missing policies. It is the strongest validation in the product, it is pinned by a unit test (get_claims_should_throw_an_exception_if_policy_used_in_route_was_not_defined), and it is the reason the sentinel collision above is theoretical rather than exploitable.
The caveat is that it is enforced as a side effect of construction. PolicyManager is a singleton, and singletons are activated lazily. It happens to be constructed during UseNtrada, five levels deep: AddRoutes resolves IRouteProvider, which needs IRequestExecutionValidator, which needs IAuthorizationManager, which needs IPolicyManager. If a future refactor made RouteProvider resolve its validator lazily, or moved the auth gate behind a factory, the check would move to the first authenticated request — or, on a config with no protected routes at all, never run. A security invariant enforced by activation order is not enforced; it is observed. The fix is one line in UseNtrada: resolve IPolicyManager explicitly and discard it, so the check has a caller with a name and a reason.
Next, building a second container to read one value — the composition root, and the signature that forced its worst line.