Authorization rules
Source TraxAuthorizeAttribute.cs, TraxAllowAnonymousAttribute.cs, TraxAuthorization.cs, TrainAuthorizationService.cs, ExposureAuthorizationRule.cs, TypeExtensionExposureRule.cs, TraxHttpAuthenticationInterceptor.cs, ITrainAuthorizationService.cs
Security notice. NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See Secure a GraphQL host.
Every surface the Trax GraphQL endpoint exposes declares who may reach it, and a host whose declarations are missing or contradictory does not start. This page lists the rules. Why they are shaped this way is in Where Trax checks authorization; every refusal they produce is in Startup refusals.
Gates
| Gate | Declared with | Checked |
|---|---|---|
| The whole endpoint | RequireAuthorization(policy?) on the GraphQL builder | In the request pipeline, for every operation on HTTP and on a subscription socket |
The operations namespace | GateOperations(policy, roles), GateOperationsToAuthenticatedUsers(), or AllowAnonymousOperations() | @authorize on the operations field of both root types |
| A train | [TraxAuthorize] on the train class, a base class or an interface | By ITrainAuthorizationService when the train is run or queued, before its input is read |
| A query model, or an entity one reaches | [TraxAuthorize] on the entity class | @authorize on the object type and on the model's discover entry field |
| A field a type extension adds | [TraxAuthorize] on the resolver method or the extension class | @authorize on the field |
| Who receives a train's runs over a subscription | The [TraxBroadcast] train's own [TraxAuthorize] or [TraxAllowAnonymous] | Per subscriber; see Subscriptions |
[TraxAllowAnonymous] declares a train, entity or field intentionally public. Both attributes apply
to a class, an interface and a method. [TraxAuthorize] may be applied more than once;
[TraxAllowAnonymous] may not.
Required exposure posture
A surface is exposed when it is a [TraxQuery], [TraxMutation] or [TraxBroadcast] train, or a
[TraxQueryModel] entity. Each declares [TraxAuthorize] or [TraxAllowAnonymous], directly or
through a base class or interface:
| Endpoint | Neither marker | [TraxAuthorize] | [TraxAllowAnonymous] | Both |
|---|---|---|---|---|
| Not gated (the default) | refused at startup | gated | public | refused at startup |
Gated with RequireAuthorization() | allowed: the endpoint gate covers it | allowed: the attribute adds its policy and roles | refused at startup: it cannot take effect | refused at startup |
A train with none of [TraxQuery], [TraxMutation] and [TraxBroadcast] is not reachable over
GraphQL and needs no marker.
Combining with endpoint-level auth
The endpoint gate and the per-surface attributes both run: the endpoint gate in the request
pipeline first, a train's requirements when it is run or queued, a type's directive when it is
resolved. The endpoint gate that counts for the checks above is the builder's
RequireAuthorization(); an ASP.NET Core convention on the mapped route
(UseTraxGraphQL(configure: e => e.RequireAuthorization())) is enforced but does not count for
these checks.
How policies and roles combine
Each [TraxAuthorize] is a requirement, and every requirement must pass. Inside one attribute,
Roles is a comma-separated list, any one of which is enough.
| Attributes | The caller must |
|---|---|
[TraxAuthorize] | be authenticated |
[TraxAuthorize("P")] | pass policy P |
[TraxAuthorize(Roles = "A,B")] | hold role A or role B |
[TraxAuthorize("P", Roles = "A")] | pass P and hold A |
[TraxAuthorize("P1")] [TraxAuthorize("P2")] | pass P1 and P2 |
[TraxAuthorize(Roles = "A")] [TraxAuthorize(Roles = "B")] | hold A and hold B |
[TraxAuthorize("P")] [TraxAuthorize(Roles = "A")] | pass P and hold A |
The rule is the same on a train, a query model, an entity, a resolver method, an extension class
and GateOperations (each call is one requirement), and it holds wherever the attributes come
from: a class and its interfaces and base classes, an extension class and its method. A
method-level [TraxAuthorize(Roles = "Support")] under a class-level
[TraxAuthorize(Roles = "Admin")] requires both roles.
Roles match exactly and case-sensitively, as ClaimsPrincipal.IsInRole compares them against
each identity's role claim type: Roles = "Admin" does not match a role claim of admin. Roles
need no registration. A policy is an ASP.NET Core authorization policy, evaluated with
IAuthorizationService, and must be registered; a policy name that is not registered refuses the
host at startup, wherever it is named.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("Admin"));
options.AddPolicy("MustBeInternal", policy => policy.RequireClaim("network", "internal"));
});Attribute shape is checked at startup too: an empty or whitespace Policy, and a Roles list that
holds no role, are refused.
Per-train authorization
using Trax.Core.Functional;
using Trax.Effect.Attributes;
using Trax.Effect.Services.ServiceTrain;
// Any authenticated caller.
[TraxQuery]
[TraxAuthorize]
public class WhoAmITrain : ServiceTrain<Unit, UserInfo>, IWhoAmITrain
{
protected override Task<Either<Exception, UserInfo>> Junctions() =>
Chain<ReadUserJunction>().Resolve();
}
// The "Admin" policy.
[TraxMutation]
[TraxAuthorize("Admin")]
public class DeleteUserTrain : ServiceTrain<DeleteUserInput, Unit>, IDeleteUserTrain
{
protected override Task<Either<Exception, Unit>> Junctions() =>
Chain<DeleteUserJunction>().Resolve();
}
// The "MustBeInternal" policy, and either the Admin or the Manager role.
[TraxMutation]
[TraxAuthorize("MustBeInternal")]
[TraxAuthorize(Roles = "Admin,Manager")]
public class GenerateReportTrain : ServiceTrain<ReportInput, ReportOutput>, IGenerateReportTrain
{
protected override Task<Either<Exception, ReportOutput>> Junctions() =>
Chain<GenerateReportJunction>().Resolve();
}
// Public on purpose.
[TraxQuery(Namespace = "public")]
[TraxAllowAnonymous]
public class LookupAnnouncementTrain
: ServiceTrain<LookupAnnouncementInput, AnnouncementOutput>,
ILookupAnnouncementTrain
{
protected override Task<Either<Exception, AnnouncementOutput>> Junctions() =>
Chain<LookupAnnouncementJunction>().Resolve();
}Discovery reads [TraxAuthorize] and [TraxAllowAnonymous] from the implementation class, its
base classes and every interface it implements, so an attribute on IMyTrain applies when the
class carries none, and a decorated train keeps the requirements of what it wraps.
ITrainExecutionService calls the registered ITrainAuthorizationService for every train on
every RunAsync, QueueAsync and PrepareAsync, before the input JSON is read. Every caller-built
run or enqueue goes through it: the GraphQL train fields, the operations namespace's
workQueue.queueTrain, workQueue.runTrain, requeueExecution and resumeExecution, and the
dashboard. AddTraxGraphQL (through AddTraxApi) registers the default, TrainAuthorizationService,
which decides in this order.
Fail-closed behavior
| Condition | Result |
|---|---|
The train has no [TraxAuthorize] | Allowed |
| A trusted execution scope is open on the flow | Allowed; the skip is logged at Information with the scope's reason |
No HttpContext | Refused: "No request context and no trusted execution scope." |
| The request user is not authenticated | Refused: "No authenticated user." |
| A required policy does not pass | Refused: "Policy 'P' not satisfied." |
The user holds no role of one attribute's Roles list | Refused: "User lacks required role. Required one of: ..." |
| Otherwise | Allowed |
A refusal is a TrainAuthorizationException. Its Message is always "Not authorized."; the reason
above and the train name are on its Reason and TrainName properties, for logs. GraphQL returns
it with code TRAX_AUTHORIZATION and that message, and an IOperationsService call surfaces it
the same way. The train does not run.
The scheduler and remote workers are trusted
Work the scheduler dispatches and a remote worker runs goes through ITrainBus or a trusted scope
and is not checked again, and so is a train a system-owned state machine instance starts. A train a
user-owned instance starts is checked against that user. See
Where Trax checks authorization.
Opting out for scheduler-only hosts
A registered [TraxAuthorize] train with no ITrainAuthorizationService refuses the host at
startup, and at run time a call to such a train outside a trusted scope throws
TrainAuthorizationNotConfiguredException. A process that discovers gated trains but takes no
submissions, a scheduler-only worker for example, opts out:
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
services.AddTrax(trax => trax
.AddEffects(effects => effects.UsePostgres(connectionString))
.AddMediator(mediator => mediator
.ScanAssemblies(typeof(Program).Assembly)
.AllowMissingAuthorizationService()));In that process every [TraxAuthorize] train runs with no authorization check. An API host never
calls it.
A custom enforcer
ITrainAuthorizationService (in Trax.Mediator) has one method,
Task AuthorizeAsync(TrainRegistration registration, CancellationToken ct), which returns to allow
and throws to refuse. It is called for every train, including trains with no [TraxAuthorize],
and inside a trusted scope: the mediator does not check ITrustedExecutionScope.IsTrusted first,
so an implementation that should skip trusted work checks it itself. RequiredPolicies holds every
policy to pass; RequiredRoles is only the union of every attribute's roles, and checking it as
any-of admits a caller one of the attributes refuses. Throw TrainAuthorizationException to get
TRAX_AUTHORIZATION and the generic message; another exception type reaches GraphQL as an
unmapped error.
A decorator over the default keeps its policy and role checks and adds one:
using Trax.Api.Exceptions;
using Trax.Api.Services.Authorization;
using Trax.Mediator.Services.TrainAuthorization;
using Trax.Mediator.Services.TrainDiscovery;
using Trax.Mediator.Services.TrustedExecution;
public sealed class ActiveTenantAuthorizationService(
TrainAuthorizationService defaults,
IHttpContextAccessor httpContextAccessor,
ITrustedExecutionScope trustedScope
) : ITrainAuthorizationService
{
public async Task AuthorizeAsync(TrainRegistration registration, CancellationToken ct = default)
{
// Policies, roles, and the refusal of a call with no request and no trusted scope.
await defaults.AuthorizeAsync(registration, ct);
if (!registration.HasAuthorizeAttribute || trustedScope.IsTrusted)
return;
var tenantStatus = httpContextAccessor.HttpContext?.User.FindFirst("tenant_status")?.Value;
if (tenantStatus != "active")
throw new TrainAuthorizationException(registration.ServiceTypeName, "Tenant is not active.");
}
}using Trax.Api.GraphQL.Extensions;
using Trax.Api.Services.Authorization;
using Trax.Mediator.Services.TrainAuthorization;
// After AddTraxGraphQL, which registers the default; the last registration is the one resolved.
builder.Services.AddTraxGraphQL(graphql => graphql.RequireAuthorization());
builder.Services.AddScoped<TrainAuthorizationService>();
builder.Services.AddScoped<ITrainAuthorizationService, ActiveTenantAuthorizationService>();Per-model authorization
[TraxAuthorize] on a [TraxQueryModel] entity puts @authorize on its object type and on its
entry field under discover. The combinators and role matching are the ones above.
| How the request reaches the entity | What is checked |
|---|---|
Its entry field, discover.<namespace>.<field> | The field's directive, before the resolver. Connection fields such as totalCount and pageInfo are refused with it, so an unauthorized caller cannot count the rows. |
A navigation from an ungated parent (discover.publicOwners.nodes[].privateBooks) | The type's directive, as each node is completed. The parent's rows are read; the gated branch is replaced by an error and its rows are not returned. |
A where or order on any field that passes through a navigation to the entity | The entity's directives, before the query runs. A caller who may read the entity filters and sorts through it; anyone else gets TRAX_AUTHORIZATION for the whole field. This holds on a model's entry field, on a type extension's [UseFiltering] or [UseSorting] resolver, and on a [UseFiltering] collection navigation. |
using System.ComponentModel.DataAnnotations.Schema;
using Trax.Effect.Attributes;
[TraxQueryModel(Namespace = "library")]
[TraxAuthorize(Roles = "Subscriber")]
[Table("premium_articles", Schema = "content")]
public class PremiumArticle
{
public long Id { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
}A caller who is not a Subscriber, anonymous or not, gets TRAX_AUTHORIZATION with the message
"Not authorized." from discover.library.premiumArticles, and from any other field whose type is
PremiumArticle.
At host start the schema is checked: a gated entity whose type or entry field has lost its
@authorize, or an anonymous one that has gained it, through a ConfigureSchema callback, refuses
the host.
Entities a query model reaches
A navigation property, or a public method returning an entity (GetOwner(), bound as owner),
reaches an entity whether or not it is a query model, and puts its object type, filter input and
sort input in the schema. On an endpoint not gated by RequireAuthorization(), such an entity
declares its own posture with [TraxAuthorize] or [TraxAllowAnonymous] on its class, even
behind a gated model.
using Trax.Effect.Attributes;
[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; } = "";
}The other answers are leaving the navigation out of the model's field set (BindFields = Explicit
or ExposeAs, which narrow the filter and sort inputs too) and gating the endpoint. A class EF
Core maps as owned is part of the entity that holds it and needs no marker.
Anonymous access via [TraxAllowAnonymous]
[TraxAllowAnonymous] declares a surface intentionally public. On a train it adds no runtime
behaviour: train authorization is enforced by the enforcer, not by a schema directive, and the
attribute only satisfies the exposure check. On an entity it leaves the type without
@authorize, so any caller reads it.
Openness does not cascade
[TraxAllowAnonymous] on an entity opens that entity only. A navigation to a gated entity is gated
behind it:
using Trax.Effect.Attributes;
[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; }
}
[TraxQueryModel(Namespace = "matches")]
[TraxAuthorize(Roles = "Admin")]
public class MatchRecord
{
public long Id { get; set; }
public string MatchId { get; set; } = "";
}An anonymous discover.public.publicAnnouncements { title } succeeds. Selecting
relatedMatch { matchId }, filtering on relatedMatch.matchId or ordering by it returns
TRAX_AUTHORIZATION.
Fields added by a type extension
An [ExtendObjectType] class adds fields to a type Trax owns. A field inherits the gate of its
parent type when the parent has one, and declares its own when it does not:
| Parent type | Marker required on the field |
|---|---|
Carries [TraxAuthorize] | No: it inherits the parent's gate |
Carries [TraxAllowAnonymous] | Yes |
A root type (RootQuery, RootMutation, LifecycleSubscriptions) | Yes |
A namespace reached from a root through an ungated field (DiscoverQueries, DispatchMutations, the per-namespace types under them) | Yes |
A type under operations, gated by GateOperations(...) or RequireAuthorization() | No: it inherits the namespace's gate |
A type under operations, with AllowAnonymousOperations() | Yes |
| A query model's navigation target | As the target's own posture: no under [TraxAuthorize], yes under [TraxAllowAnonymous] |
| A type with neither marker | No: it reaches the schema only inside a train's output, whose posture governs |
[TraxAuthorize] or [TraxAllowAnonymous] on the resolver method declares that field. On the
extension class, the attribute applies to every field the extension contributes, including fields
built from its properties, and not to the type being extended. A subscription field
([ExtendObjectType("LifecycleSubscriptions")]) is a field on a root type and declares.
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 declares.
[TraxAuthorize(Roles = "Subscriber")]
public IReadOnlyList<Excerpt> GetExcerpts([Parent] Article article) =>
[new Excerpt(article.Body[..Math.Min(200, article.Body.Length)])];
}The check runs over the merged schema at host start, so it sees an extension however it was
registered: AddTypeExtension(s), a type module from AddTypeModule<T>(), or a ConfigureSchema
callback. A field built from a lambda has no member to carry an attribute and is not checked; the
code that builds it gates it.
HotChocolate's [Authorize] and [AllowAnonymous] (HotChocolate.Authorization, and
HotChocolate.AspNetCore.Authorization from HotChocolate 15) are refused on such a field or its
extension class, gated parent or not:
GraphQL field 'Article.excerpts' (MyApp.ArticleExtensions.GetExcerpts) 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. ...ASP.NET Core's [Authorize] is not refused; it governs endpoints and MVC actions.
Which scheme authenticates a GraphQL request
[TraxAuthorize] and @authorize read HttpContext.User. No AddTrax*Auth call sets a default
authentication scheme, so AddTraxGraphQL installs TraxHttpAuthenticationInterceptor, which runs
before each GraphQL HTTP execution request:
| Condition | The interceptor |
|---|---|
The builder calls RequireAuthorization(policy) and the policy names schemes | Authenticates with that policy's schemes |
| Otherwise, the request is already authenticated (a default scheme, other middleware) | Leaves it |
| Otherwise | Tries every registered scheme in registration order and keeps the first principal that authenticates |
| No scheme authenticates | Leaves the caller anonymous; every gate refuses it with TRAX_AUTHORIZATION |
A credential a scheme rejects counts as no credential. A request carrying an API key and a bearer
token is authenticated by whichever scheme was registered first. The interceptor does not run for
the IDE page or WebSocket upgrades; a subscription authenticates from its connection_init payload
through TraxCompositeSocketInterceptor; see
Subscriptions.
The operations surface
| Action | Chooses the train and input | Authorized by |
|---|---|---|
workQueue.queueTrain, workQueue.runTrain, requeueExecution, resumeExecution | The caller | The operations gate, and the train's own [TraxAuthorize] through ITrainExecutionService |
triggerManifest, triggerManifestDelayed, triggerGroup, the dead-letter requeues | A manifest | The operations gate only |
| The dashboard's queue dialog, Re-queue, and Run dialog | The dashboard user | The dashboard host's gate. The calls run inside a trusted scope ("dashboard"), so the train's [TraxAuthorize] is skipped; OnQueue, the input cap and, for a queue, QueueSubjectKey apply |
| The ManifestManager, and dormant dependents a parent train activates | The system | Nothing further: the work runs inside a scheduled or already authorized train, and skips QueueSubjectKey and OnQueue |
A run started through workQueue.runTrain or the dashboard's Run dialog runs the train's OnQueue
hook and the input cap, and is handed to the job submitter the train is routed to; it does not
take its QueueSubjectKey, so it is not serialized against other work for that subject. Outside a
trusted scope, a train that declares QueueSubjectKey is refused a run and must be queued.
operations.trains lists each train's requiredPolicies and requiredRoles. requiredRoles is
the union of every attribute's roles; it does not say which attributes they came from.
What the attributes do not cover
- A property on its own.
[TraxAuthorize]does not apply to a property. A resolver method on the entity or on a train's output type can carry it, and that field is gated on every host. To make one column admin-only, expose it through a gated method, use a train, or split the entity withExposeAs. - Rows.
[TraxAuthorize]decides whether a caller may read a type, not which of its rows. Scope rows with EF Core query filters that read the caller, such as one built overTraxCaller; see Owner-scoped contexts. Key ownership on thetrax:principal-idclaim as it is,{scheme}:{id}. Nothing in Trax's authorization sees those filters; the owner-scope census inTrax.Effect.Data.Testingdoes. - The record a caller names. A caller who passes a train's gate supplies its input, and so the
subject key
QueueSubjectKeyderives from it and the priority it queues at. Subject keys are one space across all trains. Authorize the record inOnQueue, which runs with the caller's identity, or scope the key per tenant.
See also
- Where Trax checks authorization
- Startup refusals
- Secure a GraphQL host
- TraxAuthorize, TraxAllowAnonymous, ITrustedExecutionScope, AddTraxGraphQL, Query models
- The Auth sample uses each gate in one host, and its E2E suite proves them.
SDK Reference
TraxAuthorize | TraxAllowAnonymous | ITrustedExecutionScope | AddTraxGraphQL | ITrainExecutionService | TraxQuery / TraxMutation | TraxQueryModel