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. AllowUnenforcedAuthorizationAttributes exists 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 and UseAuthentication(); 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 IAuthorizationPolicy and [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.auth activity 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 = true shipped 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] without AddRelayAuth, 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.

➡️ 011 — The Outbox Pattern