Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/three-predicates-one-flag && read --section="top" 0%
.NET

Three Predicates, One Flag

Three components decide independently whether a route needs authentication, only one of them consults auth.enabled - and the combination that turns a public route into a 403.

By Kumar Chandrachooda 17 Feb 2026 7 min read
One switch feeding three gates, two open and one shut, disagreeing about the same signal

The most expensive configuration bugs are not the ones where a setting does nothing. They are the ones where a setting does something in two places and something slightly different in a third, so the system's behaviour is a function of which component you happen to be reading. You test it, it works; you flip the flag, it works; you flip the flag in combination with another flag and the request 403s with a log line that tells you which route and which user and nothing about why.

Part 7 dissected a parser that is correct by coincidence. This part is about a predicate that is written three times, and about the one configuration key that only one of those three copies reads.

The flag, and its four consumers

Ntrada's auth configuration is three keys deep:

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

enabled: turns the authentication stack on. global: decides whether every route requires it or only the ones that opt in with auth: true. A per-route auth: is bool?, so it can force on, force off, or inherit — the three-state primitive from part 3.

Four separate pieces of code consume this. Here they are side by side.

Consumer one — the composition root decides whether the middleware exists.

if (options.Auth?.Enabled == true)
{
    logger.LogInformation("Authentication is enabled.");
    app.UseAuthentication();
}
else
{
    logger.LogInformation("Authentication is disabled.");
}

NtradaExtensions.cs:174-182

Reads Enabled. Only Enabled.

Consumer two — the request pipeline decides whether to run the gate.

var skipAuth = _options.Auth is null ||
               !_options.Auth.Global && routeConfig.Route.Auth is null ||
               routeConfig.Route.Auth == false;

RouteProvider.cs:51-53

Reads Global and the route flag. Does not read Enabled at all.

Consumer three — the authentication manager decides whether to call the framework.

if (_options.Auth is null || !_options.Auth.Enabled || _options.Auth?.Global != true &&
    routeConfig.Route?.Auth != true)
{
    return true;
}

var result = await request.HttpContext.AuthenticateAsync();

return result.Succeeded;

AuthenticationManager.cs:20-28

Reads Enabled, Global and the route flag. This is the complete predicate, and it is the only one.

Consumer four — the boot log decides what to call the route.

var isPublicInfo = _options.Auth is null || !_options.Auth.Global && route.Auth is null ||
                   route.Auth == false
    ? "public"
    : "protected";

UpstreamBuilder.cs:49-52

Character-for-character the same expression as consumer two, in a different file, for a different purpose. Also ignores Enabled.

Three copies of one idea, two of which are wrong about what enabled: false means, and the copy that is right is the one nobody reads first.

Tracing enabled: false, global: true

That combination is not a straw man. It is what you write when you want to stage a rollout: leave the policy structure in place, describe every route as protected, and keep the auth stack off until the identity provider is ready. Here is what the gateway does with it, for a route that declares claims: or policies:.

  1. Boot, consumer one. Auth?.Enabled == true is false. app.UseAuthentication() is not called. There is no authentication middleware in the pipeline, so context.User will be the framework's default anonymous ClaimsPrincipal on every request. The log says “Authentication is disabled.” — which is true and correct.
  2. Boot, consumer four. Auth is not null, Global is true, so the ternary falls to the second branch. Every route is logged as Added protected route for upstream: [GET] '/orders' -> .... The startup log now contains both “Authentication is disabled” and a list of protected routes.
  3. Request, consumer two. Auth is not null; !Global is false so the second clause fails; route.Auth == false is false. skipAuth is false. The gate runs.
  4. Request, consumer three. !_options.Auth.Enabled is true, so TryAuthenticateAsync returns true without touching the framework. The request is treated as authenticated.
  5. Request, authorisation. RequestExecutionValidator proceeds to _authorizationManager.IsAuthorized(context.User, routeConfig). The user is the anonymous principal from step 1. The route's claims: are evaluated against it with user.HasClaim(key, value) and none of them match.

The caller gets 403 Forbidden, with an empty body, from a gateway whose startup log said authentication was disabled. The log line that accompanies it is:

Forbidden request to: /orders by user:  [Trace ID: 0HM4T...]

— the user name is empty because there is no user, and the message never names the claim that failed. You get the route and a blank, and you are left to guess.

If the route declares no claims: and no policies:, the same trace ends in success — HasClaims(user, null) returns true — but the request has still taken the slow path through two managers and an authorisation evaluation it did not need. So the flag's behaviour depends on a property of the route that has nothing to do with the flag.

What the tests already knew

It would be unfair to present this as carelessness, because the author clearly thought hard about exactly this truth table. AuthenticationManagerTests is the most rigorous file in the repository:

[Fact]
public async Task try_authenticate_should_return_true_if_global_auth_is_disabled()
{
    _options.Auth = new Configuration.Auth
    {
        Enabled = true
    };
    var result = await Act();
    result.ShouldBeTrue();
    await _authenticationService.DidNotReceiveWithAnyArgs().AuthenticateAsync(null, null);
}

AuthenticationManagerTests.cs:30-40

The assertion that matters is the second one. It is not enough that the method returned true; the test pins that the framework's AuthenticateAsync was not called. That is a performance contract expressed as a behavioural one, and it is stated four times across the file for four combinations of enabled, global and the route flag. Whoever wrote that understood the state space precisely.

The gap is not comprehension; it is that the state space is only encoded in one of the four places that consume it. AuthenticationManager is tested and correct. RouteProvider.Handle and UpstreamBuilder are untested and each carry a hand-copied approximation of the same rule. A predicate duplicated across three files does not have three implementations; it has one implementation and two forks that nobody remembers to merge.

The two-key design underneath

It is worth asking why there are two keys at all, because the shape of the bug is a consequence of the shape of the configuration. enabled and global are not orthogonal in any useful way: enabled: false makes global meaningless, and global: true is incoherent without enabled: true. Of the four combinations, one is a contradiction and one is redundant.

What the DSL actually wants is a single key with three values — off, opt-in, everywhere:

auth:
  mode: opt-in     # off | opt-in | global
  policies:
    admin:
      claims:
        role: admin

One key, three named states, no combination that can contradict itself, and no way for a copy of the predicate to omit half of it because there is no half to omit. Boolean pairs invite exactly this failure: the moment two flags interact, the interaction lives in whoever reads them, and it lives there once per reader. The three-state bool? primitive that Ntrada uses so well at the route level is the same insight applied to a different axis; it just never made it up to the section header.

The refactor that removes the whole class

The prescription is the boring one and it works. There is already a per-route object created once at startup by IRouteConfigurator, and it is already the thing every consumer holds:

public class RouteConfig
{
    public Route Route { get; set; }
    public string Downstream { get; set; }

    // computed once, at Configure() time
    public bool RequiresAuth { get; set; }
}

with the single canonical predicate computed in RouteConfigurator.Configure:

private static bool RequiresAuth(NtradaOptions options, Route route)
{
    if (options.Auth is null || !options.Auth.Enabled) return false;
    if (route.Auth == true) return true;
    if (route.Auth == false) return false;
    return options.Auth.Global;
}

RouteProvider.Handle becomes if (routeConfig.RequiresAuth && !await _requestExecutionValidator.TryExecuteAsync(...)). UpstreamBuilder's log reads the same property, so the boot log and the runtime behaviour can no longer disagree. AuthenticationManager keeps its own check as a defence-in-depth guard, or loses it — either is fine, because there is now one authority.

That refactor also collapses a related cost. As part 2 noted, roughly ten per-route constants are recomputed on every request in this gateway: skipAuth, skipPayload, three of the four HasTransformations clauses, the payload key's string interpolation, the compiled JSON schema, the three-level resource-id property fallback, the effective header dictionaries with their .Any() calls. Every one of them is a function of configuration that cannot change after boot. RouteConfig has two fields. Ntrada compiles its routes into the framework's endpoint table — the hard part — and then declines to compile anything else, which means the performance fix and the correctness fix here are the same refactor.

One last, narrow hazard while we are in this file. The 403 log dereferences context.User.Identity.Name, and AuthorizationManager.IsAuthorized guards only user is null — not a principal whose Identity is null, which new ClaimsPrincipal() produces and which an extension or hook could assign. In that case the failure log itself throws NullReferenceException and your 403 becomes a 500. It is unlikely, and it is one ?. away from impossible.

Next, the claims map that does not exist — the documented fix for exactly this 403, and why it has never done anything.