Where Trax checks authorization
Trax authorizes GraphQL work in a few places and deliberately not in others. The rules themselves are on Authorization rules; this page is about why they are shaped the way they are, and what each choice costs.
Nothing is public by accident
A GraphQL server built the usual way is open until someone adds a gate. A new mutation, a new
entity in a query model, a resolver bolted onto an existing type: each ships public unless its
author remembered. Trax inverts that. Every surface the endpoint exposes states its posture, gated
with [TraxAuthorize] or public with [TraxAllowAnonymous], and a host where one does not is
refused at startup with a message naming it. The cost is one attribute per surface and a host that
will not start until it is written. The benefit is that "public" is something a reviewer can see in
a diff.
The same reasoning covers the places a surface can appear without its author thinking of it as a
surface: an entity a query model reaches through a navigation, which HotChocolate puts in the
schema with its own filter and sort inputs; a field a type extension adds to a public type or a
root; a [TraxBroadcast] train, whose runs stream to subscribers. Each answers the same question.
The operations namespace, which reads execution inputs and drives the scheduler, has no
anonymous default at all: exposing it means choosing a gate, a whole-endpoint gate, or an explicit
AllowAnonymousOperations().
Everything that can be decided before the first request is decided then. A policy name that is not registered would turn a gate into a silent deny-all, so it refuses the host instead. A demo key in a published sample would authenticate anyone who read the sample, so outside Development it refuses the host too. Startup refusals lists them.
Trax owns the vocabulary
HotChocolate has [Authorize] and [AllowAnonymous] of its own, and Trax refuses them on the
fields it checks. This is not about naming. The same question answered by two attributes would
have two sets of combinator rules and two inheritance behaviours, and code written against one
would be coupled to the GraphQL server underneath, which Trax exists to keep out of a consumer's
domain code. [TraxAuthorize] works on a train, which never reaches the schema as a type and on
which HotChocolate's attribute means nothing; it works on an entity, an interface and a resolver.
The difference that bites is placement. HotChocolate applies a class-level attribute on an
[ExtendObjectType] class to the type being extended: on a public entity it re-locks the whole
entity, and on a root type it sets the posture of every operation in the schema. [TraxAuthorize]
on an extension class applies to the fields that extension contributes, which is what someone
writing it there means. ASP.NET Core's [Authorize] is a different surface, endpoints and MVC
actions, and is left alone. The decision is recorded in Trax.Docs/adr/0013.
Combining follows ASP.NET Core's rule for repeated [Authorize] attributes: each attribute is a
requirement and all must pass, so adding an attribute can only narrow who gets in. Earlier
versions merged role lists across attributes into one any-of list, so a method-level role widened
a class-level one (Trax.Api/docs/adr/0030). Roles used to be compared case-insensitively after
upper-casing both sides, which let a culture's case mapping equate characters a reader sees as
different; they now match exactly, as @authorize and RequireRole do (Trax.Docs/adr/0026).
Two enforcement points
A train is checked imperatively. ITrainExecutionService calls the enforcer before it reads the
input, so a caller who may not run a train learns nothing about the input it expects, not even
from a parse error. A query model is checked declaratively: Trax translates [TraxAuthorize] into
HotChocolate's @authorize directive on the object type and on the entry field. It has to be both.
The field directive alone would leave the type readable through a navigation from an ungated
parent; the type directive alone would leave totalCount and pageInfo answering for a caller who
may not read a single row. Filtering and sorting through a navigation reveal as much as selecting
it, and an input field cannot carry a directive, so Trax evaluates the target type's directives
itself before such a query runs.
The endpoint gate, RequireAuthorization() on the builder, is a third and coarser layer: one
policy for every operation. It is not redundant with the per-surface attributes. It answers "may
this caller reach the API at all", they answer "may this caller run this train or read this type",
and both run. Trax counts only the builder's gate in its startup checks, because it is the one it
can see; an ASP.NET Core convention on the mapped route still works, enforced before HotChocolate
runs, but is invisible to the census.
Checked once, at submission
Authorization is enforced when work is submitted, where there is a request and a caller. A run or enqueue that passes is the caller's, and what it puts on the work queue is already authorized. Everything downstream is trusted infrastructure:
- The scheduler dequeues and calls
ITrainBus.RunAsyncdirectly, which never reaches the enforcer. - A remote worker runs what the scheduler sends it. A queued job goes through the job runner and
ITrainBus; a remote run (UseRemoteRun) goes throughITrainExecutionServiceinside a trusted scope ("scheduler.remote-run"), so the enforcer skips it. - A system-owned state machine instance starts its trains inside a trusted scope, as a scheduled manifest would. A user-owned instance's trains are authorized against that user.
So a train can carry [TraxAuthorize("Admin")] and still be scheduled with AddScheduler() or run
on a remote worker. Re-checking it there would need a caller who is not present: a scheduled run
has no request, and the person who defined the manifest may have left the company.
Trust is explicit, never inferred. A missing HttpContext is not trust: outside a trusted scope,
the default enforcer refuses a gated train that has no request ("No request context and no trusted
execution scope."), so a background service or a test that calls the execution service directly
cannot slip past by accident. A trusted scope is opened in code, with ITrustedExecutionScope,
around work that was authorized at a gate of its own, and its reason is logged when it skips a
check. A custom enforcer is called inside trusted scopes too and decides itself what trust means;
it should key on IsTrusted, not on whether a request is present.
Because a remote worker trusts what it is sent, its own endpoints are where that trust is guarded.
Every runner entry point refuses to start without an authorization posture: a signing key shared
with the scheduler, a policy that admits only the scheduler, or an explicit, logged
AllowUnsignedRequests(). See Remote execution. Inside a
remote run TraxCaller.IsTrusted is true, so anything keyed on it, row-level filters included,
treats the request as the scheduler's.
The operations surface and the dashboard
The admin surface enqueues work in two ways, and they are authorized differently
(Trax.Docs/adr/0017).
When the caller chooses the train and the input (workQueue.queueTrain, workQueue.runTrain,
requeueExecution, resumeExecution), the train's own [TraxAuthorize] applies on top of the
operations gate. Passing the operations gate makes someone an operator, not a holder of every
train's role.
When a manifest chooses them (triggering a manifest or a group, requeueing a dead letter), only the operations gate applies. The train and input were fixed when the manifest was defined, so triggering it is operating the scheduler rather than submitting new work. The whole trigger and dead-letter surface sits behind one admin gate, and an operator who passes it can trigger any manifest. Protect it accordingly.
The operations console holds no authority of its own. Every read and action it offers is an
operations request, so the operations gate decides who may use it, and a run or a queue it asks
for checks the train's [TraxAuthorize] against the caller as any other GraphQL caller's does; the
train's OnQueue hook and the input cap apply too. Whoever loaded the console's page proves
nothing to the API: its posture gates the page, not the data. A scheduler-only host still has to
register an enforcer or call AllowMissingAuthorizationService(), because the startup check that
refuses a host with gated trains and no enforcer does not know which callers it will have.
A run differs from a queue in one way that matters. A queued entry takes its train's
QueueSubjectKey and is serialized against other work for the same subject; a run started from
the Run dialog or workQueue.runTrain is handed straight to a job submitter and is not
(Trax.Docs/adr/0019). The dashboard warns about this; outside a trusted scope a subject-keyed
train cannot be run this way at all and must be queued.
What a gate does not decide
[TraxAuthorize] gates a train or a type. It does not gate a record. A caller who may run a train
supplies its input, and the input is what QueueSubjectKey turns into a subject key, so the caller
also chooses which subject the entry serializes against, and the priority it queues at. Subject
keys are one space across all trains. Where that matters, authorize the record in OnQueue, which
runs with the caller's identity available, or scope keys per tenant so a key a caller can name is
one they own.
Rows are the same. A gated entity with no row filter serves every user's rows to any caller who
passes the gate. Trax's authorization does not see EF Core query filters, which is why the
owner-scope census in Trax.Effect.Data.Testing exists.