Content Shield
NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.
samples/EphemeralWorkers in Trax.Samples is a content
moderation API whose trains all run somewhere else: on a runner that is an AWS Lambda function in
production and a local Kestrel server in development. The API holds no workers and writes no
background_job rows. It POSTs each queued job to the runner and returns, and it POSTs each
synchronous run and waits for the answer.
What it proves
| Feature | Where |
|---|---|
Queued mutations dispatched over HTTP with UseRemoteWorkers; nothing reaches background_job | RemoteExecutionTests |
Every synchronous run (run mutations and [TraxQuery] queries) sent to the runner with UseRemoteRun | RemoteExecutionTests.Query_is_run_on_the_runner |
A runner that is a TraxLambdaFunction, served locally by RunLocalAsync | Runner/Function.cs, Runner/Program.cs |
A signing key shared by both sides; an unsigned request to the runner gets 401 | RunnerSigningKey.cs, AuthorizationTests |
The runner's lifecycle events reaching a GraphQL subscriber on the API over RabbitMQ, on the sample's own contentshield.lifecycle exchange | CrossProcessEventTests |
| Testing a Lambda runner end to end without AWS | tests/Trax.Samples.ContentShield.E2E/Fixtures/TestRunner.cs |
Layout
samples/EphemeralWorkers/
├── Trax.Samples.ContentShield/ trains, roles, RunnerSigningKey
├── Trax.Samples.ContentShield.Api/ GraphQL + dispatch + dashboard (port 5204)
└── Trax.Samples.ContentShield.Runner/ Function : TraxLambdaFunction; Program.cs runs it locally (port 5205)Run
From the Trax.Samples root:
docker compose up -d # Postgres on 5432 (database trax_contentshield), RabbitMQ on 5672 (user trax, password trax123)
# Terminal 1: the runner on http://localhost:5205
dotnet run --project samples/EphemeralWorkers/Trax.Samples.ContentShield.Runner
# Terminal 2: the API on http://localhost:5204
dotnet run --project samples/EphemeralWorkers/Trax.Samples.ContentShield.ApiBoth start in Development through Properties/launchSettings.json (ASPNETCORE_ENVIRONMENT for the
API, DOTNET_ENVIRONMENT for the runner) and sign with a published demo key. Anywhere else, set
Trax__RunnerSigningKey to the same value on both, the base64 of 32 or more random bytes
(openssl rand -base64 32); without it the API refuses to start with
Set Trax:RunnerSigningKey to the base64 of 32 or more random bytes ....
Try it
# Look up a moderation result (anonymous; the API waits while the runner runs it)
curl -s http://localhost:5204/trax/graphql -H "Content-Type: application/json" \
-d '{"query":"{ discover { moderation { lookupModerationResult(input: {contentId: \"test-001\"}) { contentId moderationStatus classification threatScore } } } }"}'
# Queue a review (anonymous). It returns at once; the runner's console logs the review.
curl -s http://localhost:5204/trax/graphql -H "Content-Type: application/json" \
-d '{"query":"mutation { dispatch { moderation { reviewContent(input: {contentId: \"test-002\", contentType: \"video\", contentBody: \"suspicious video content\"}) { externalId workQueueId } } } }"}'
# Run a report on the runner and wait for the output (Moderator key, Development only)
curl -s http://localhost:5204/trax/graphql -H "Content-Type: application/json" \
-H "X-Api-Key: contentshield-moderator-key-do-not-use-in-production" \
-d '{"query":"mutation { dispatch { reports { generateModerationReport(input: {reportPeriod: \"Daily\"}) { externalId output { totalReviewed totalFlagged topViolationTypes falsePositiveRate } } } } }"}'
# The runner refuses anything not signed with the key
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:5205/trax/execute \
-H "Content-Type: application/json" -d '{}'The report answers with its output, and the last command prints 401. The same report without the
key answers Not authorized. with code TRAX_AUTHORIZATION.
How it works
The API dispatches everything
using Trax.Samples.ContentShield;
using Trax.Scheduler.Extensions;
var runnerBaseUrl = builder.Configuration["Runner:BaseUrl"] ?? "http://localhost:5205";
var runnerKey = RunnerSigningKey.Resolve(builder.Configuration, builder.Environment.IsDevelopment());
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(connectionString)
.UseBroadcaster(b =>
b.UseRabbitMq(rabbitMqConnectionString, o => o.ExchangeName = LifecycleEvents.ExchangeName)
)
)
.AddMediator(mediator => mediator.ScanAssemblies(typeof(ReviewContentTrain).Assembly))
.AddScheduler(scheduler =>
scheduler
.UseRemoteWorkers(
remote =>
{
remote.BaseUrl = $"{runnerBaseUrl}/trax/execute";
remote.SigningKey = runnerKey;
},
routing =>
routing
.ForTrain<IReviewContentTrain>()
.ForTrain<ISendViolationNoticeTrain>()
.ForTrain<IGenerateModerationReportTrain>()
)
.UseRemoteRun(remote =>
{
remote.BaseUrl = $"{runnerBaseUrl}/trax/run";
remote.SigningKey = runnerKey;
})
)
);UseRemoteWorkers routes the named trains' queued runs to the runner. A train it does not route
still falls back to the local worker, which is why the API process still registers
LocalWorkerService; every queueable ContentShield train is routed, so that worker never has a job.
UseRemoteRun replaces the in-process run executor for every synchronous run, which includes
[TraxQuery] queries as well as mutations in RUN mode. So even lookupModerationResult travels to
the runner. Leave UseRemoteRun out if queries should run on the API.
The runner is a Lambda function
using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Trax.Effect.Broadcaster.RabbitMQ.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Runner.Lambda;
using Trax.Samples.ContentShield;
using Trax.Scheduler.Configuration;
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
public class Function : TraxLambdaFunction
{
protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UsePostgres(configuration.GetConnectionString("TraxDatabase")!)
.UseBroadcaster(b =>
b.UseRabbitMq(
configuration.GetConnectionString("RabbitMQ")!,
o => o.ExchangeName = LifecycleEvents.ExchangeName
)
)
)
.AddMediator(typeof(ReviewContentTrain).Assembly)
);
}
// Without a posture the function refuses every request.
protected override void ConfigureRunner(TraxJobRunnerOptions runner, IConfiguration configuration) =>
runner.SigningKey = RunnerSigningKey.Resolve(configuration, IsDevelopment(configuration));
}A runner runs what it is sent as already authorized, so it refuses every request until it knows who
may send work. The signing key is that answer: the API signs each request body with it, and the runner
checks the Trax-Signature header before reading the body. See
Remote Execution: Authorization Posture.
The function reads its configuration from appsettings.json next to the binary and from environment
variables (ConnectionStrings__TraxDatabase, Trax__RunnerSigningKey, DOTNET_ENVIRONMENT). It does
not see command-line arguments, and it has no IHostEnvironment, which is why IsDevelopment reads
DOTNET_ENVIRONMENT from configuration.
Locally, Program.cs serves the function over HTTP:
await new Function().RunLocalAsync([$"--contentRoot={AppContext.BaseDirectory}", .. args]);The --contentRoot argument matters. The Kestrel server RunLocalAsync starts reads
appsettings.json from its content root, which defaults to the current directory, while the
function reads the copy next to the binary. Started from the repository root without it, the runner
finds no Kestrel endpoint and listens on 5000 instead of 5205, and the API's requests go nowhere.
For production, swap UseRemoteWorkers and UseRemoteRun for UseLambdaWorkers and UseLambdaRun
(package Trax.Scheduler.Lambda), which invoke the function through the AWS SDK with no public
endpoint, with the same SigningKey. See
Lambda Workers.
Who may do what
Anyone may submit content (reviewContent) and look up a result. sendViolationNotice and
generateModerationReport carry [TraxAuthorize(Roles = "Moderator")]; the only key with that role
is a demo key registered in Development. The runner needs no authorization service for them: a remote
run executes inside a trusted scope, because the API already checked the caller. The dashboard is
registered and served only in Development, with AllowAnonymousDashboard().
Only the Moderator trains, sendViolationNotice and generateModerationReport, are
[TraxBroadcast]. A broadcast train's events carry every run's output to every subscriber the train
admits, so the anonymous review and lookup are not broadcast, and a subscriber on the API sees only
the Moderator trains.
The API and the runner both publish on contentshield.lifecycle (LifecycleEvents.ExchangeName),
an exchange of their own, so another Trax application on the same broker does not receive their
events. See UseBroadcaster: RabbitMQ.
Subscriptions need a credential even for anonymous trains. Once an API-key scheme is registered,
every WebSocket must carry a key in its connection_init payload, and a socket without one is
refused; see Subscriptions: Authentication.
Tests
TRAX_TEST_PG_PORT=5432 dotnet test tests/Trax.Samples.ContentShield.E2ENo AWS is involved. The suite starts the real Function through RunLocalAsync on a free port, from
a subclass that overrides BuildServiceProvider only to supply the test database and broker and to
count the requests the runner handles, and points the API's Runner:BaseUrl at it. Both use the
contentshield_e2e_tests database.
| Test class | Proves |
|---|---|
RemoteExecutionTests | A query and a run mutation reach /trax/run, a queued review reaches /trax/execute, and no background_job row is written |
AuthorizationTests | Reports and notices refuse an anonymous caller; the runner refuses unsigned requests on both routes |
CrossProcessEventTests | A violation notice the runner sends reaches an onTrainCompleted subscriber on the API; an anonymous review's result is not broadcast |
DocumentedExamplesTests | Every curl command in the API's header works as written |
SDK Reference
UseRemoteWorkers | UseRemoteRun | TraxLambdaFunction | UseLambdaWorkers | UseBroadcaster | AddTraxApiKeyAuth