Articles · Tenancy & Security ·010
Authorization
Runnable sample on GitHub
Sample:
samples/010-authorization— declarative auth on a Documents API.dotnet run/dotnet test. No database.Prerequisites: 001 — Getting Started, 004 — Errors & HTTP.
Overview
Authorization in Relay is declared on the message, not the endpoint. A command or query carries an
attribute — [RequireRole("Editor")], [RequirePermission("documents:publish")], [AllowAnonymous] —
and the authorization pipeline behaviors (priority 0, the outermost wrappers from article 001) enforce
it before any work happens, throwing UnauthorizedException (401) or ForbiddenException (403). The
system is fail-closed: an unattributed message requires an authenticated user, and the host refuses
to start if authorization attributes exist but AddRelayAuth() was never called. Credential validation
stays where it belongs — in ASP.NET Core authentication; Relay only consumes the resulting principal.
Why this exists
The usual place to put authorization — [Authorize] on a controller action — guards exactly one entry
point: an HTTP request to that action. But a command can be dispatched from more than one place: an
HTTP endpoint today, a background job tomorrow, a saga next quarter, a message consumer after that. If
the authorization lives on the controller, every other path into that command is unguarded, and the
gap is invisible until someone exploits it. Putting authorization on the message closes this: wherever
the command flows, through whatever entry point, the same rule travels with it and the same behavior
enforces it.
Two more problems follow. Fail-open is the default failure mode of hand-rolled auth — a new command
ships, someone forgets the [Authorize], and it’s wide open until a pentest finds it. Relay inverts the
default: forget the attribute and the message requires authentication (fail-closed); forget
AddRelayAuth() entirely and the host won’t start. And credential validation keeps leaking into
business code — handlers parsing tokens, re-checking claims. Relay draws a hard line: ASP.NET Core
validates credentials and produces a ClaimsPrincipal; Relay’s middleware projects that into an
AuthContext; the behaviors enforce declarative rules. Handlers never touch authentication.
When to use this
Use Relay authorization on every service with operations that aren’t fully public — which is nearly all of them:
- Documents / content (the sample): anyone may read public docs; only Editors may create; only holders of a publish permission may publish.
- Payments / orders: only the account owner or an admin may view a payment; only specific roles may refund.
- Admin operations:
RotateKeysCommand,DeleteUserCommand— gated by role or a named policy. - Multi-tenant SaaS: combine role/permission checks with tenant scoping (article 020) so a user only acts within their tenant.
The signal: any command or query where “who is asking?” changes whether it may run. If you can name a single operation that an anonymous or under-privileged caller must not perform, you need this — and you want it on the message so it can’t be bypassed.
When not to use this
- A genuinely, entirely public service. A read-only public API with no protected operation has
nothing to authorize. Mark everything
[AllowAnonymous](so the fail-closed default doesn’t bite) and move on — but be honest that “no auth” is a decision, not an oversight. - A trusted internal worker with no external surface. A background processor that no untrusted caller can reach may not need per-message authorization (though defense-in-depth often still wants it).
- You need a fundamentally different authz model (e.g. fine-grained, relationship-based ReBAC like
Zanzibar). Relay’s attribute + named-policy model covers RBAC and claim-based rules well; for
relationship graphs you’ll integrate a dedicated authorization service — likely via a
[RequirePolicy]that calls it.
The costs: a small vocabulary to learn (the [Require*] attributes); the discipline to attribute every
message (the fail-closed default helps); and the need to wire real authentication (JWT/cookies) — Relay
deliberately does not do credential validation, so you still set that up.
Concepts
The attributes. [AllowAnonymous] (no auth), [RequireAuthentication] (any signed-in user),
[RequireRole(...)], [RequirePermission(...)], [RequireClaim(type, value)], and [RequirePolicy(name)]
(a named IAuthorizationPolicy). All attributes on a message are evaluated — combining
[RequireAuthentication] and [RequireRole("Admin")] requires both.
Fail-closed. An unattributed message requires an authenticated user. If any registered message
carries a [Require*] attribute and AddRelayAuth() was not called, the host throws at startup with
an actionable error (an explicit, tracked-as-debt opt-out exists for services with no auth story yet).
The bridge. Credential validation is ASP.NET Core’s job (AddAuthentication().AddJwtBearer(...) +
UseAuthentication()). The RelayAuthorizationMiddleware (or UseRelayAuthContext()) projects the
validated ClaimsPrincipal into an AuthContext (a GUID user id from the sub/nameidentifier claim,
roles from role claims, and every other claim — including multi-valued permission claims) that the
behaviors read.
Named policies. For rules an attribute can’t express, implement IAuthorizationPolicy (a Name +
evaluation methods returning AuthorizationResult.Success()/Fail(...)) and guard with [RequirePolicy("Name")].
Policy evaluation is fail-closed: an unexpected failure denies; an unknown policy name surfaces as a
configuration error (500), not a 403.
Mapping. UnauthorizedException → 401, ForbiddenException → 403, via the Web exception mapper
(article 004) — matched by full type name, so the Web package needs no dependency on Auth.
Architecture
sequenceDiagram
participant C as Client (token)
participant A as ASP.NET auth (validates credentials)
participant M as RelayAuthorizationMiddleware (principal → AuthContext)
participant B as Authorization behavior (priority 0)
participant H as Handler
C->>A: request + credentials
A->>M: HttpContext.User (ClaimsPrincipal)
M->>M: project → AuthContext (user id, roles, claims)
M->>B: dispatch command/query
B->>B: evaluate [Require*] against AuthContext
alt allowed
B->>H: Handle(...)
else denied
B-->>C: throw Unauthorized(401) / Forbidden(403)
end
Authorization runs at priority 0 — before validation, logging, and (crucially) before any transaction opens. You never do work, or open a database transaction, for a request you’re going to reject.
Building it step by step
The sample is samples/010-authorization.
1. Declare authorization on the messages
[RelayHttpGet("/documents")] [AllowAnonymous] public sealed record ListDocumentsQuery : IQuery<IReadOnlyList<Document>>;
[RelayHttpGet("/documents/{id}")] [RequireAuthentication] public sealed record GetDocumentQuery(string Id) : IQuery<Document?>;
[RelayHttpPost("/documents")] [RequireRole("Editor")] [SkipTransaction] public sealed record CreateDocumentCommand(string Title, string Body) : ICommand<Document>;
[RelayHttpPost("/documents/{id}/publish")] [RequirePermission("documents:publish")] [SkipTransaction] public sealed record PublishDocumentCommand(string Id) : ICommand<Document>;
2. Register the authorization behaviors
services.AddRelay(typeof(Program).Assembly);
services.AddRelayAuth(); // without this, the host won't start (attributes exist)
services.AddRelayExceptionHandling(...); // maps 401/403
3. Wire authentication, then the bridge
// production:
services.AddAuthentication().AddJwtBearer(/* ... */);
app.UseAuthentication();
app.UseRelayAuthContext(); // principal → AuthContext
// this sample uses a header-based dev shim in place of real authentication, then the same bridge:
app.Use(async (ctx, next) => { DevAuthentication.ApplyFromHeaders(ctx); await next(ctx); });
app.UseMiddleware<RelayAuthorizationMiddleware>();
Complete source code
| File | Contents |
|---|---|
Documents/Endpoints.cs |
Messages with [AllowAnonymous]/[RequireAuthentication]/[RequireRole]/[RequirePermission] |
DevAuthentication.cs |
Header → ClaimsPrincipal shim (stands in for real auth) |
DocumentsServiceCollectionExtensions.cs |
AddRelayAuth + exception handling |
Running the example
dotnet run --project samples/010-authorization/Documents.Api
curl -i localhost:5000/documents/doc-1 # 401
curl -i localhost:5000/documents/doc-1 -H 'X-User-Id: <a-guid>' # 200
Testing
One test per attribute, asserting the three outcomes — 401 (anonymous), 403 (authenticated but not allowed), 200 (allowed) — that the behaviors produce without a line of status-code logic in any handler:
[Fact] public async Task Creating_a_document_requires_the_Editor_role()
{
(await client.SendAsync(Request(POST, "/documents", userId: guid, body: body))) // no role
.StatusCode.Should().Be(HttpStatusCode.Forbidden);
(await client.SendAsync(Request(POST, "/documents", userId: guid, roles: "Editor", body))) // with role
.StatusCode.Should().Be(HttpStatusCode.OK);
}
Testing authorization at the HTTP boundary (not by unit-testing a check in a handler) is the point: it proves the behavior enforces the rule for the real dispatch path, the one an attacker would use.
Production considerations
- Keep fail-closed; don’t make the opt-out permanent.
AllowUnenforcedAuthorizationAttributesexists only for a service with no auth story yet, and downgrades the startup guard to a Critical log. Treat its presence as tracked security debt with a ticket, not a config setting you leave on. - Authorize on the message so every path is covered. The whole reason to put auth here rather than
on controllers is that commands flow through sagas, jobs, and consumers too. Don’t also sprinkle
[Authorize]on controllers and assume that’s enough — the message attribute is the guarantee. - Validate credentials with ASP.NET Core, not in handlers. Wire
AddJwtBearer/cookies andUseAuthentication(); let Relay consume the principal. A handler that parses a token is a smell and a bug waiting to diverge from the real auth. - Use named policies for anything beyond role/permission. “Owner or admin,” “within business
hours,” “tenant matches” — express these as an
IAuthorizationPolicyand[RequirePolicy(...)]rather than stuffing logic into handlers. Policies are testable and reusable. - Combine with tenancy. In multi-tenant systems, authorization (who) and tenancy (which tenant, article 020) are separate, composable behaviors. A user may be an Editor in their tenant — enforce both.
- Audit denials. Authorization evaluation runs inside a
relay.authactivity with error status on denial; feed that into your telemetry (article 021) so repeated 403s are visible.
Common mistakes
- Authorizing only at the controller. Bypassable the moment the command is dispatched from anywhere else. Put the rule on the message.
- Forgetting
AddRelayAuth(). The startup guard catches this — heed it, don’t suppress it. A host that “won’t start because of authorization attributes” is the framework protecting you. - Leaving the opt-out on.
AllowUnenforcedAuthorizationAttributes = trueshipped to production means your[Require*]attributes are decoration that enforce nothing. It is a stopgap, not a setting. - Validating credentials in Relay/handlers. Token parsing belongs in ASP.NET Core authentication. Handlers that re-validate are duplicating (and eventually contradicting) the real check.
- Over-broad roles.
[RequireRole("User")]on everything is authentication wearing an authorization costume. Use permissions/policies for actual capability checks; reserve roles for coarse grouping. - Assuming
[AllowAnonymous]is the default. It is the opposite — the default is “authenticated required.” Mark public messages explicitly, or they’ll (correctly) reject anonymous callers.
Tradeoffs
Benefits. Authorization travels with the message, so every entry point is covered; fail-closed by default (forgetting a rule denies, not permits); credential validation stays in ASP.NET Core; named policies handle complex rules; and 401/403 mapping is automatic.
Costs. A vocabulary and a discipline (attribute every message); you still wire real authentication yourself; and very fine-grained / relationship-based models need an external authorization service (integrated via a policy).
Alternatives
- ASP.NET Core
[Authorize]on controllers/endpoints. The platform standard. Guards HTTP entry points well, but only HTTP — a command reachable from a saga or job is unguarded. Use it with Relay for the HTTP layer if you like, but rely on the message attribute for the actual guarantee. If your commands are only ever dispatched from one HTTP action, controller-level auth may suffice — but that assumption rarely survives contact with growth. - Manual checks in handlers.
if (!user.IsInRole("Editor")) throw …. Maximally explicit, maximally scattered and forgettable — exactly the fail-open trap. Avoid for anything beyond a one-off. - An external authorization service (OPA, Zanzibar-style). For fine-grained or relationship-based
policy. The right call when RBAC isn’t enough; integrate it behind a
[RequirePolicy]so the rest of the system still declares authorization the same way.
Objectively: declarative message-level authorization wins wherever a command has more than one possible caller and “fail-closed” is the safe default — which is almost always.
Lessons from production systems
- The unguarded second entry point is the classic breach. A command is authorized on its controller, then someone wires it into a saga or an admin job without the check, and now there’s a privilege- escalation path nobody designed. Message-level authorization is the structural fix; teams that adopt it stop having this class of incident.
- Fail-closed defaults turn omissions into 500s, not breaches. The first time the startup guard
refuses to boot because someone added
[RequireRole]withoutAddRelayAuth, it feels like friction; the alternative is shipping an unenforced rule. The friction is the feature. - Roles sprawl; permissions and policies scale. Teams that model everything as roles end up with fifty roles and no one sure what they grant. The durable pattern is coarse roles for grouping plus fine-grained permissions/policies for actual capability checks.
- Keeping credential validation out of business code pays off at every auth change. When the token format, the IdP, or the claim mapping changes, teams that kept it in ASP.NET Core authentication change one place; teams that let handlers parse tokens change dozens and miss some.
Should you use this?
| Situation | Recommendation |
|---|---|
| Any service with non-public operations | Strong fit — authorize the message, fail closed |
| Commands dispatched from multiple paths (HTTP, sagas, jobs) | Strong fit — the message attribute covers them all |
| Complex rules (owner-or-admin, time/tenant-based) | Strong fit — named policies |
| A fully public, read-only API | Mark [AllowAnonymous] — and own it as a decision |
| Relationship-based / fine-grained ReBAC | Integrate an external service behind [RequirePolicy] |
| Trusted internal worker, no external surface | Optional — still useful as defense in depth |
Next steps
Everything so far has lived inside a single service. The remaining articles cross service boundaries. The next one introduces the pattern that makes publishing events to other services reliable — without the dual-write bug where you save your state but fail to publish (or vice versa): the outbox.