Security notice. NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.
Authorization
Trax supports three levels of authorization for the API layer:
- Endpoint-level: gate all Trax endpoints behind a single policy using the
configurecallback onUseTraxGraphQL. This is standard ASP.NET Core endpoint authorization. - Per-train: restrict individual trains using the
[TraxAuthorize]attribute on the train class. When a request comes in to run or queue a train, Trax checks the attribute against the current HTTP user before executing anything. - Per-model: restrict
[TraxQueryModel]-exposed entities using the same[TraxAuthorize]attribute on the entity class. The directive is enforced at GraphQL type level, so the gate also applies when the entity is reached through a navigation property on an ungated parent, whether the request selects it or filters or sorts through it.
A surface exposed via GraphQL can also be explicitly opened to anonymous access with [TraxAllowAnonymous]. See Anonymous Access via TraxAllowAnonymous below.
Endpoint-level auth answers "can this user reach the Trax API at all?" Per-train auth answers "can this user execute this particular train?" Per-model auth answers "can this user read rows of this particular type, regardless of how they navigated to it?"
Required Exposure Posture
Every surface exposed via GraphQL must state its authorization posture explicitly. A [TraxQuery]/[TraxMutation] train or a [TraxQueryModel] entity must declare either [TraxAuthorize] (gated) or [TraxAllowAnonymous] (intentionally public). A surface that declares neither fails at host startup, so a forgotten gate can never silently ship as a public endpoint. The check runs in AddTraxGraphQL (trains) and TraxGraphQLBuilder.Build() (query models); the failure message names every offending type.
The one relaxation is endpoint-level gating: RequireAuthorization() on the GraphQL builder covers every surface at the transport layer, so a per-surface marker is no longer required. Because nothing is reachable anonymously under that gate, [TraxAllowAnonymous] becomes a contradiction there and is rejected.
| Endpoint posture | neither marker | [TraxAuthorize] | [TraxAllowAnonymous] | both |
|---|---|---|---|---|
| Open (default) | fails startup (implicitly public) | gated | intentionally public | fails startup (conflict) |
Gated (RequireAuthorization()) | allowed (gate covers it) | allowed (adds finer policy/role) | fails startup (contradiction) | fails startup (conflict) |
RequireAuthorization() and [TraxAuthorize] are not redundant: the endpoint gate is a single transport-level check ("must be authenticated / satisfy this one policy"), while [TraxAuthorize] adds per-surface policy and role requirements on top. They compose as defense in depth.
This rule applies only to surfaces that are actually exposed via GraphQL. A train with no [TraxQuery]/[TraxMutation] (run only through the scheduler, a remote worker, or ITrainBus directly) is never reachable over GraphQL and needs no marker.
Fields Added by a Type Extension
An [ExtendObjectType] class adds a field to a type Trax owns. That field is neither a train nor a query model, so the census above cannot see it, and what gates it in practice is inheritance from the parent type's @authorize. That holds right up until the parent has no gate to inherit, and then the field is public because nobody looked.
A field added this way must declare its own posture when the parent gives it nothing:
| Parent type | Marker required on the field |
|---|---|
Carries [TraxAuthorize] | No. It inherits the parent's gate. |
Carries [TraxAllowAnonymous] | Yes. It inherits nothing. |
A schema root type (RootQuery, RootMutation, LifecycleSubscriptions) | Yes. There is no parent to inherit from. |
A namespace Trax reaches from the root through an ungated field (DiscoverQueries, DispatchMutations, and the per-namespace types under them) | Yes. It is as reachable as the root. |
A type under the operations namespace | No. It takes the posture declared for the namespace: GateOperations(...), RequireAuthorization() or AllowAnonymousOperations(). |
| Neither marker | No. The type reaches the schema only inside a train's output, whose posture governs, or as a query model's navigation target, which must declare its own posture (see Entities a Query Model Reaches). |
[TraxAuthorize] and [TraxAllowAnonymous] both apply to a method, so a resolver declares its posture directly and Trax emits the matching @authorize directive for it. The combinator semantics are the same as on a train or an entity: policies AND, roles union and OR.
using HotChocolate;
using HotChocolate.Types;
using Trax.Effect.Attributes;
[ExtendObjectType(typeof(Article))] // Article is [TraxAllowAnonymous]
public sealed class ArticleExtensions
{
// Inherits nothing from Article, so it says what it is.
[TraxAuthorize(Roles = "subscriber")]
public async Task<IReadOnlyList<Excerpt>> GetExcerpts([Parent] Article article) => ...;
}On the extension class, [TraxAuthorize] applies to every field that extension contributes, including a field HotChocolate builds from a property of the class, which cannot carry the attribute itself. It does not touch the type being extended, so gating an extension of a [TraxAllowAnonymous] entity does not re-gate the entity.
A subscription is the same construct: [ExtendObjectType("LifecycleSubscriptions")] adds a field to a root type, so every [Subscribe] field has to declare.
The check walks the merged schema at host start, so it sees a type extension however it was registered: through AddTypeExtension(s), from a type module added with AddTypeModule<T>(), or from a ConfigureSchema callback. A field built from a lambda in such a callback has no member to carry an attribute and is skipped; it is gated by the code that builds it.
Do Not Use HotChocolate's [Authorize] or [AllowAnonymous]
Trax refuses them. A surface that declares its posture with HotChocolate.Authorization.Authorize or HotChocolate.Authorization.AllowAnonymous fails at host startup, with a message naming the Trax replacement:
GraphQL field 'Issue.content' (Nwyc.IssueContentExtension.GetContent) declares its authorization
posture with [Authorize], which Trax does not read. Trax owns this vocabulary so a surface
declares the same way wherever it lives and whatever GraphQL server is underneath: use
[TraxAuthorize] to gate it, or [TraxAllowAnonymous] to open it to anonymous callers.This is not a style preference. [TraxAuthorize] exists so that a consumer declares authorization once, in one vocabulary, on a train or an entity or a resolver, and Trax translates it into whatever the server underneath needs. Accepting the server's attributes alongside Trax's would mean the same question is answered by two attributes with different combinator rules and different inheritance behaviour, and it would couple consumer code to the dependency [TraxAuthorize] exists to hide.
Two concrete differences, not just naming:
- Class-level placement. HotChocolate applies a class-level attribute on an
[ExtendObjectType]to the type being extended. On a[TraxAllowAnonymous]entity that re-locks the whole entity; on a root type it sets the posture of every operation in the schema.[TraxAuthorize]applies to the fields the extension contributes. - Reach.
[TraxAuthorize]works on a train, an entity, an interface and a resolver. HotChocolate's attribute means nothing on a train, which never reaches the schema as a type.
ASP.NET Core's [Authorize] is a different matter and is not banned: it governs endpoints and MVC actions, a surface Trax does not own. RequireAuthorization() on the GraphQL endpoint is the supported way to gate the transport.
Migrating is mechanical:
| Replace | With |
|---|---|
[Authorize] | [TraxAuthorize] |
[Authorize(Policy = "P")] | [TraxAuthorize(Policy = "P")] |
[Authorize(Roles = ["a", "b"])] | [TraxAuthorize(Roles = "a,b")] |
[AllowAnonymous] | [TraxAllowAnonymous] |
Per-Train Authorization
Decorate any train class with [TraxAuthorize] to declare authorization requirements:
using Trax.Effect.Attributes;
// Requires an authenticated user. No specific policy or role.
[TraxAuthorize]
public class WhoAmITrain : ServiceTrain<Unit, UserInfo>, IWhoAmITrain
{
protected override Task<Either<Exception, UserInfo>> Junctions() =>
Chain<ReadUserJunction>().Resolve();
}
// Requires the "Admin" authorization policy
[TraxAuthorize("Admin")]
public class DeleteUserTrain : ServiceTrain<DeleteUserInput, Unit>, IDeleteUserTrain
{
protected override Task<Either<Exception, Unit>> Junctions() => Chain<DeleteUserJunction>().Resolve();
}
// Requires the user to have at least one of the listed roles
[TraxAuthorize(Roles = "Manager,Admin")]
public class GenerateReportTrain : ServiceTrain<ReportInput, ReportOutput>, IGenerateReportTrain
{
protected override Task<Either<Exception, ReportOutput>> Junctions() => Chain<GenerateReportJunction>().Resolve();
}
// No attribute, no per-train auth check. Valid only because this train is not
// exposed via GraphQL (no [TraxQuery]/[TraxMutation]). A GraphQL-exposed train
// with no marker fails startup (see Required Exposure Posture above).
public class PingTrain : ServiceTrain<PingInput, PongOutput>, IPingTrain
{
protected override Task<Either<Exception, PongOutput>> Junctions() => Chain<PingJunction>().Resolve();
}The attribute supports two properties:
| Property | Type | Description |
|---|---|---|
Policy | string? | Name of an ASP.NET Core authorization policy to evaluate |
Roles | string? | Comma-separated list of roles. The user must have at least one. Role comparison is exact and case-sensitive. |
The attribute works on classes, interfaces, and base classes. Trax unions the attributes it finds across the implementation type's interface chain and base chain, so [TraxAuthorize("Admin")] on an IMyTrain interface is honored even when the implementing class carries no attribute. Decorator-wrapped trains inherit their authorization requirements through the same mechanism.
Role comparison is exact and case-sensitive, the way @authorize on a query model and ASP.NET Core's RequireRole compare roles (ClaimsPrincipal.IsInRole, ordinal against each identity's role claim type): [TraxAuthorize(Roles = "Admin")] does not match a principal carrying ClaimTypes.Role = "admin". Train subscriptions compare the same way. Declare roles in the casing your identity provider issues them. Before this, TrainAuthorizationService upper-cased both sides, so admin matched Admin, and a culture case mapping could equate characters a reader sees as different (see Trax.Docs/adr/0026).
How Policies and Roles Combine
Multiple attributes and mixed policy/role specifications combine as follows:
| Attribute form | What the user must satisfy |
|---|---|
[TraxAuthorize] (bare) | Be authenticated. No additional policy or role requirement. |
[TraxAuthorize("P")] | Policy P must pass. |
[TraxAuthorize(Roles = "A,B")] | Hold role A OR role B. |
[TraxAuthorize("P", Roles = "A")] | Policy P must pass AND hold role A. |
[TraxAuthorize("P1")] [TraxAuthorize("P2")] | Both P1 and P2 must pass. Policies AND across attributes. |
[TraxAuthorize(Roles = "A")] [TraxAuthorize(Roles = "B")] | Hold role A OR role B. Roles union OR across attributes. |
[TraxAuthorize("P")] [TraxAuthorize(Roles = "A")] | Policy P must pass AND hold role A. |
In short: every policy has to pass, and at least one role from the unioned list has to match. A bare attribute requires nothing beyond authentication.
[TraxAuthorize("MustBeInternal")]
[TraxAuthorize(Roles = "Admin,Manager")]
public class SensitiveTrain : ServiceTrain<SensitiveInput, Unit>, ISensitiveTrain { ... }The caller must pass the MustBeInternal policy and hold either Admin or Manager.
Per-Model Authorization
Decorate any [TraxQueryModel] entity with [TraxAuthorize] to gate the auto-generated GraphQL query. The combinator semantics, role normalization, and inheritance behavior all match the per-train surface above. The only difference is enforcement: Trax attaches HotChocolate's native @authorize directive to the generated ObjectType and to the entry field under discover, so the gate fires in two places:
- Direct entry: a top-level
discover.<namespace>.<modelField>request runs the field-level directive before the resolver. Connection-shaped scalars liketotalCountandpageInfoare blocked too; an unauthorized caller cannot enumerate the cardinality of a gated entity. - Transitive navigation: a request that reaches the entity through a navigation property on an ungated parent (
discover.publicOwners.nodes[].privateBooks) triggers the type-level directive when each child node is materialized. The parent's data is still selected from the database, but the unauthorized response substitutes an error for the gated branch and never returns the row payload to the client. - Filtering and sorting through a navigation: a
whereororderon an ungated parent that passes through a navigation to the entity (publicOwners(where: { privateBooks: { some: { title: { eq: "..." } } } })) is authorized against the entity's directives before the query runs, exactly as selecting it would be. A caller who may read the entity filters and sorts through it as usual; anyone else getsTRAX_AUTHORIZATIONfor the whole field. HotChocolate's@authorizecannot sit on an input field, so Trax evaluates the target type's own directives through HotChocolate's authorization handler.
using System.ComponentModel.DataAnnotations.Schema;
using Trax.Effect.Attributes;
[TraxQueryModel(Namespace = "library")]
[TraxAuthorize(Roles = "Subscriber")]
[Table("articles", Schema = "content")]
public class Article
{
public long Id { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
}Anonymous and non-subscriber callers hitting discover.library.articles (or any field elsewhere in the schema whose return type is Article) receive a TRAX_AUTHORIZATION error with the public message "Not authorized.".
Combinator Semantics
Identical to the per-train surface. The table below repeats them for reference; see Per-Train Authorization for the prose explanation.
| Attribute(s) on the entity | Meaning |
|---|---|
[TraxAuthorize] (bare) | Be authenticated. |
[TraxAuthorize("P")] | Policy P must pass. |
[TraxAuthorize(Roles = "A,B")] | Hold role A OR role B. |
[TraxAuthorize("P1")] [TraxAuthorize("P2")] | Both P1 and P2 must pass. Policies AND across attributes. |
[TraxAuthorize(Roles = "A")] [TraxAuthorize(Roles = "B")] | Hold role A OR role B. Roles union OR across attributes. |
[TraxAuthorize("P")] [TraxAuthorize(Roles = "A")] | Policy P must pass AND hold role A. |
Startup Validation
QueryModelAuthorizationValidator runs as a hosted service at host start. It throws if any entity references an authorization policy that has not been registered via services.AddAuthorization(...). This catches typoed policy names ("AdmnPolicy") before the first request rather than turning the gate into a silent deny-all in production. Roles are not validated against the principal store (Trax has no view of what roles can exist); attribute shape (empty or whitespace-only Policy, all-empty Roles CSV) is validated at TraxGraphQLBuilder.Build time.
Authentication for Per-Model Gating
Per-model [TraxAuthorize] enforcement runs HotChocolate's @authorize directive, which evaluates against HttpContext.User. ASP.NET Core's UseAuthentication() middleware only populates HttpContext.User from the default authentication scheme, so multi-scheme hosts (api-key + JWT, api-key + cookie, etc.) where no scheme is configured as the default would otherwise leave HC seeing an anonymous principal.
Trax handles this automatically. When any registered [TraxQueryModel] entity carries [TraxAuthorize], AddTraxGraphQL wires a HotChocolate IHttpRequestInterceptor (QueryModelAuthenticationInterceptor) that runs before each GraphQL HTTP request. The interceptor:
- Returns early if the request is already authenticated by upstream middleware or endpoint-level
RequireAuthorization. - Otherwise, walks every registered authentication scheme and attempts
AuthenticateAsyncagainst each. The first successful scheme wins; the resulting principal is assigned toHttpContext.Userfor the duration of the request. - If no scheme matches the request's credentials, the principal stays anonymous - gated queries will then reject with
TRAX_AUTHORIZATION.
The interceptor runs only for GraphQL HTTP execution requests, so the Nitro IDE page and WebSocket subscription upgrades are not affected. Subscriptions authenticate separately, through TraxCompositeSocketInterceptor and the per-scheme strategies it delegates to (TraxApiKeySocketInterceptor, TraxJwtSocketInterceptor, TraxJwtDispatcherSocketInterceptor).
No consumer configuration is required.
Limitations
- Field-level gating inside an entity is not supported.
[TraxAuthorize]on a property is ignored. IfUser.emailmust be admin-only butUser.displayNamemust be public, use a custom train rather than[TraxQueryModel], or split the entity into two types viaExposeAs. - Row-level filtering is not supported.
[TraxAuthorize]answers "can this user read this type", not "which rows of this type." For per-row scoping (tenancy, ownership, subscription tier), use EF Core global query filters that read the currentClaimsPrincipalfrom a scoped service. Key ownership on thetrax:principal-idclaim as it is,{scheme}:{id}: the scheme is part of the id, so one issuer'ssubcannot match another's. See Qualified Principal Ids. Nothing in Trax's authorization sees those filters, so an entity with a correct[TraxAuthorize]and a missing filter serves every user's rows to any authenticated caller. The owner-scope census inTrax.Effect.Data.Testingis the check that does see them.
Anonymous Access via [TraxAllowAnonymous]
[TraxAllowAnonymous] is the explicit opt-in counterpart to [TraxAuthorize]. It declares a GraphQL-exposed surface intentionally public and satisfies the Required Exposure Posture rule. It applies to both [TraxQueryModel] entities and [TraxQuery]/[TraxMutation] trains.
On a query-model entity it opens that entity to unauthenticated GraphQL reads (and, as below, mirrors HotChocolate's [AllowAnonymous] cascade behavior). On a train it carries no runtime gate of its own (train authorization is enforced imperatively, not via schema directives); its only effect is to declare the train intentionally public so it passes the exposure check.
using Trax.Effect.Attributes;
// An intentionally public train.
[TraxQuery(Namespace = "public")]
[TraxAllowAnonymous]
public class LookupAnnouncementTrain
: ServiceTrain<LookupAnnouncementInput, AnnouncementOutput>, ILookupAnnouncementTrain
{
protected override Task<Either<Exception, AnnouncementOutput>> Junctions() =>
Chain<LookupAnnouncementJunction>().Resolve();
}Decorating a [TraxQueryModel] entity with it opens that entity to unauthenticated GraphQL reads.
using Trax.Effect.Attributes;
[TraxQueryModel(Namespace = "public")]
[TraxAllowAnonymous]
[Table("announcements", Schema = "content")]
public class PublicAnnouncement
{
public long Id { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
}Any caller, with or without authentication, can read discover.public.announcements. Authenticated callers are not excluded; the attribute just removes the gate.
Cascade Behavior
[TraxAllowAnonymous] is local to the decorated entity. A navigation property whose target type carries [TraxAuthorize] still enforces that gate, even when reached through the anonymous parent:
[TraxQueryModel(Namespace = "public")]
[TraxAllowAnonymous]
public class PublicAnnouncement
{
public long Id { get; set; }
public string Title { get; set; } = "";
public long? RelatedMatchId { get; set; }
public MatchRecord? RelatedMatch { get; set; } // gated below
}
[TraxQueryModel(Namespace = "matches")]
[TraxAuthorize(Roles = "Admin")]
public class MatchRecord
{
public long Id { get; set; }
public string MatchId { get; set; } = "";
}An anonymous query for discover.public.announcements { title } succeeds. The same query selecting relatedMatch { matchId } fires the gated child's directive and returns TRAX_AUTHORIZATION, and so does announcements(where: { relatedMatch: { matchId: { eq: "..." } } }) or an order by relatedMatch.matchId. The anonymous parent does not propagate openness to its gated children.
Entities a Query Model Reaches
HotChocolate builds an object type, a filter input and a sort input for every navigation a query model exposes, including a navigation to an entity that is not a [TraxQueryModel]. That entity is part of the schema, so it states its posture the same way a model does: [TraxAuthorize] or [TraxAllowAnonymous] on its own class.
[TraxQueryModel(Namespace = "public")]
[TraxAllowAnonymous]
public class Post
{
public long Id { get; set; }
public string Title { get; set; } = "";
public Account? Author { get; set; } // not a query model
}
[TraxAuthorize(Roles = "Admin")] // required: Post reaches it
public class Account
{
public long Id { get; set; }
public string Email { get; set; } = "";
}On an endpoint not gated as a whole with RequireAuthorization(), the host refuses to start when an entity a query model reaches declares neither marker, behind a gated model too. The message names the entity and every schema coordinate that reaches it:
Entity 'MyApp.Account' is not a [TraxQueryModel], but a query model reaches it through
'Post.author', 'PostFilterInput.author', 'PostSortInput.author', ...There are three ways to answer: a marker on the entity class, leaving the navigation out of the model's exposed field set (BindFields = Explicit or ExposeAs, which narrow the filter and sort inputs too), or gating the whole endpoint. A [TraxAuthorize] on the entity gates its object type wherever it appears, and its filter and sort inputs as described above. A class EF Core maps as owned is part of the entity that holds it and needs no marker of its own. The decision is recorded in Trax.Api/docs/adr/0025.
Mutual Exclusion with [TraxAuthorize]
[TraxAllowAnonymous] and [TraxAuthorize] on the same surface (directly, or via inheritance from a base class or interface) is rejected at host startup with a message naming the type. The two attributes have opposite intents and cannot coexist.
Contradicts an Endpoint-Level Gate
If the GraphQL endpoint is gated with RequireAuthorization(), the HTTP layer rejects unauthenticated callers before HotChocolate ever runs, so a surface can never be reached anonymously. [TraxAllowAnonymous] would be a promise the endpoint structurally cannot keep, so declaring it on any exposed surface while the builder opted into RequireAuthorization() fails at host startup. Remove one or the other: drop the attribute, or drop the endpoint-level gate if the surface should be publicly reachable. (Standard ASP.NET Core endpoint authorization applied separately on the mapped route, rather than via the Trax builder, does not trip this check, matching HotChocolate's own [AllowAnonymous] behavior.)
Schema Validator Coverage
For query-model entities, the same hosted-service invariant check that asserts @authorize is present on gated entities also asserts it is absent on [TraxAllowAnonymous] entities. A custom ConfigureSchema callback that reattaches the directive to an anonymous entity throws at host start with a message naming the entity. The opt-in cannot be silently re-locked.
Registering Policies
Trax uses standard ASP.NET Core authorization policies. Register them in Program.cs the same way you would for any controller or endpoint:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("Admin"));
options.AddPolicy("MustBeInternal", policy =>
policy.RequireClaim("network", "internal"));
});Trax evaluates these policies at runtime using ASP.NET Core's IAuthorizationService. If a train requires a policy that isn't registered, the authorization check fails.
How It Works
ITrainDiscoveryServicereads[TraxAuthorize]and[TraxAllowAnonymous]attributes across the implementation, its base chain, and every implemented interface. Roles are kept as declared (before Trax.Mediator 1.23.0 they were upper-cased); roles and policies are deduplicated. The requirements (and aHasAllowAnonymousAttributeflag) are stored on eachTrainRegistration.AddTraxGraphQLenforces the Required Exposure Posture for every exposed train, andTraxGraphQLBuilder.Build()does the same for every[TraxQueryModel]entity. Both share one rule: a surface with neither marker (on an open endpoint), both markers, or[TraxAllowAnonymous]underRequireAuthorization()fails startup with a message naming the offending types.- At host start, the mediator's internal
AuthorizationRegistrationValidatorruns as a hosted service. It throws if any train carries[TraxAuthorize]but noITrainAuthorizationServiceis registered (this can be opted out of per below), and it throws on malformed attribute shapes (empty policy strings, whitespace-only roles) so typos are caught before traffic arrives. - When
ITrainExecutionService.QueueAsync()orRunAsync()runs, it invokes the registeredITrainAuthorizationServicebefore reading the input JSON. Every caller-built enqueue goes throughQueueAsync, including the operations surface and the dashboard (which enqueues inside a trusted scope); see The Operations Surface. - The default implementation (
TrainAuthorizationServicefromTrax.Api) is fail-closed. It grabs the current user fromIHttpContextAccessorand evaluates each requirement:- Policy: calls
IAuthorizationService.AuthorizeAsync(user, policyName). - Roles: passes when
user.IsInRole(role)holds for at least one required role: an exact, case-sensitive match against the caller's role claims.
- Policy: calls
- If any check fails,
TrainAuthorizationExceptionis thrown. Its publicMessageis always the generic string"Not authorized."; the train name, failing policy, and required roles live only on the exception'sTrainNameandReasonproperties for server-side logging. - GraphQL surfaces the error with code
TRAX_AUTHORIZATIONand the same generic message. The train name, policy name, and role names never cross the wire.
Fail-Closed Behavior
When an HttpContext is absent and no trusted execution scope is active, the service denies. This protects against accidental invocation from background services, tests, or custom middleware that bypasses the normal request pipeline. Scheduler and remote-worker paths explicitly mark themselves as trusted via ITrustedExecutionScope.BeginTrusted(...) so pre-authorized queued work still runs. A trusted scope lasts exactly as long as its handle: scopes nest, and disposing one while an inner scope is still open closes it without ending the inner one. When the inner one is disposed, the flow returns to the nearest scope still open, or to untrusted, never to a scope already disposed.
Opting Out for Scheduler-Only Hosts
A process that registers [TraxAuthorize] trains for discovery but never serves API submissions (e.g. a standalone scheduler worker) can opt out of the fail-closed startup check:
services.AddTrax(trax => trax
.AddEffects(effects => effects.UsePostgres(connStr))
.AddMediator(mediator => mediator
.ScanAssemblies(typeof(Program).Assembly)
.AllowMissingAuthorizationService()));Only use this from processes that genuinely never accept API submissions. The flag disables the fail-closed guard; every [TraxAuthorize] gated train will run without authorization checks in that process.
Train Discovery Shows Auth Requirements
The GraphQL trains query includes authorization metadata in the response. Consumers can use this to build UIs that show which trains are available and what access they require.
{
"serviceTypeName": "IDeleteUserTrain",
"implementationTypeName": "DeleteUserTrain",
"inputTypeName": "DeleteUserInput",
"outputTypeName": "Unit",
"lifetime": "Transient",
"inputSchema": [ ... ],
"requiredPolicies": ["Admin"],
"requiredRoles": []
}Trains without [TraxAuthorize] return empty arrays for both fields.
Combining with Endpoint-Level Auth
Per-train auth and endpoint-level auth are complementary. A typical setup might look like:
// Every operation on the endpoint requires an authenticated caller
builder.Services.AddTraxGraphQL(graphql => graphql.RequireAuthorization());
// Individual trains require specific policies
[TraxAuthorize("Admin")]
[TraxMutation]
public class AdminOnlyTrain : ServiceTrain<AdminInput, Unit>, IAdminOnlyTrain { ... }The endpoint-level check runs first. The per-train check runs inside the handler, after the train is resolved by name.
Gate the endpoint through the builder's RequireAuthorization(), not only through an ASP.NET convention on the mapped route (app.UseTraxGraphQL(configure: endpoint => endpoint.RequireAuthorization())). The builder's gate is the one Trax can see: the startup checks for exposed surfaces (the operations namespace, [TraxAllowAnonymous] contradictions) count it, and they do not count a route convention. A route convention still works as an additional layer, enforced by ASP.NET before HotChocolate runs.
The Scheduler and Remote Workers Are Trusted
Authorization is enforced once, at API submission time. When a train is queued or run through GraphQL, the request carries an HttpContext and the authorization check evaluates against the caller's identity. Work that makes it past that check is written to the queue as already-authorized.
Later execution paths are trusted:
- The scheduler dequeues from
work_queueand callsITrainBus.RunAsync()directly. It never reachesITrainAuthorizationService. - Remote workers receive work over HTTP. A queued job runs through the job runner and
ITrainBus, like the scheduler's own. A remote run (UseRemoteRun) goes throughITrainExecutionService.RunAsync()inside a trusted execution scope (ITrustedExecutionScope.BeginTrusted("scheduler.remote-run")), so the authorization check skips. A missingHttpContexton its own is not trust: outside a trusted scope, the defaultTrainAuthorizationServicerefuses a[TraxAuthorize]train with "No request context and no trusted execution scope." (see Fail-Closed Behavior). A customITrainAuthorizationServiceshould make the same distinction, keying onITrustedExecutionScope.IsTrustedrather than on whether a request is present.
This means you can safely decorate a train with [TraxAuthorize("Admin")] and still schedule it via AddScheduler(), run it from a remote worker, or both. The authorization gate is the API boundary.
Because a remote worker trusts what it is sent, the worker's 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, an authorization policy that admits only the scheduler, or an explicit, logged AllowUnsignedRequests(). 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
The admin and operations surface (the GraphQL operations namespace and the dashboard) enqueues work in two ways, and they are authorized differently. Trax.Docs/adr/0017 records the decision.
| Action | What decides the train and input | Authorized by |
|---|---|---|
queueTrain, requeueExecution | The caller | The train's own [TraxAuthorize] requirements, through ITrainExecutionService.QueueAsync, plus the operations gate |
| The dashboard's queue dialog and Re-queue button | The dashboard user | The dashboard host's own gate. They go through IOperationsService.QueueTrainAsync, and so QueueAsync, inside a trusted scope ("dashboard"), so per-train [TraxAuthorize] does not apply; OnQueue, the subject key and the input cap do |
triggerManifest, triggerManifestDelayed, triggerGroup, re-queueing dead letters | A manifest | The operations gate only (GateOperations, RequireAuthorization, or AllowAnonymousOperations), not per-train requirements |
| The dashboard's Run dialog | The dashboard user | The dashboard host's own gate. It calls IOperationsService.RunTrainAsync inside the dashboard's trusted scope, the call behind runTrain, which runs the train's OnQueue hook, applies the input cap and submits to the job submitter the train is routed to. QueueSubjectKey does not apply, so the run is not serialized against other work for its subject (Trax.Docs/adr/0019); outside a trusted scope a subject-keyed train is refused and must be queued |
| The ManifestManager, and dormant dependents a parent train activates | The system (a dormant dependent's input is chosen at runtime by the parent train's code) | Nothing further: the work runs inside a scheduled or already-authorized train. These entries skip QueueSubjectKey and OnQueue |
A caller-built enqueue is authorized before its input JSON is read, so a caller who may not run the train gets TRAX_AUTHORIZATION even when the input is malformed, and learns nothing about the input the train expects.
[TraxAuthorize] gates the train, not the record. A caller who passes it supplies the input, and the input is what QueueSubjectKey turns into a subject key, so the caller chooses which subject their entry serializes against. The queue priority is a caller-supplied argument too, so they also choose where it sits in that subject's order. Subject keys are one global space, not one per train. Where that matters, authorize the record itself in OnQueue, which runs with the caller's identity available, or scope the key per tenant so a key a caller can name is always one they own.
Triggering a manifest re-runs work whose train and input were fixed when the manifest was defined, so it is treated as operating the scheduler rather than as submitting new work. That puts the whole trigger and dead-letter surface behind a single admin gate. Protect it accordingly: an operator who passes the gate can trigger any manifest. The admin area is expected to gain its own user-provided authorization (for example Microsoft Entra) later.
The dashboard is the admin surface, gated as a whole by the host that serves it. A Blazor Server circuit has no HTTP request behind a click, so the enforcer, which reads the user from the request, could not check a user there anyway. Protect the dashboard route the way you would protect any admin UI. The mediator's runtime fail-closed check for a [TraxAuthorize] train with no ITrainAuthorizationService registered honours a trusted scope too, but that only matters where hosted services do not run, such as the Lambda runner or a bare ServiceProvider. In a hosted app, the mediator's startup check refuses to start a host that has [TraxAuthorize] trains and no ITrainAuthorizationService, trusted scope or not, so a dashboard-only or scheduler-only host must register an enforcer or call AllowMissingAuthorizationService().
Custom Authorization Logic
If policies and roles aren't enough, you can replace the default ITrainAuthorizationService with your own implementation:
public class CustomTrainAuthorizationService : ITrainAuthorizationService
{
public async Task AuthorizeAsync(
TrainRegistration registration,
CancellationToken ct = default)
{
// Your custom logic here: check a database, call an external service, etc.
// Throw to deny, return normally to allow.
}
}
// Register after AddTraxGraphQL: it calls AddTraxApi, which adds the default enforcer,
// and the container resolves the last registration
builder.Services.AddTraxGraphQL();
builder.Services.AddScoped<ITrainAuthorizationService, CustomTrainAuthorizationService>();The interface is defined in Trax.Mediator, so your implementation doesn't need to reference Trax.Api.
SDK Reference
TraxAuthorize | TraxAllowAnonymous | ITrustedExecutionScope | AddTraxGraphQL | UseTraxGraphQL | ITrainDiscoveryService | ITrainExecutionService | TraxQuery / TraxMutation | TraxQueryModel