A composable, framework-agnostic request authorization library for mediator-style pipelines.
This library provides a structured way to:
- Define explicit authorization requirements per request
- Compose requirements into complex logical trees
- Evaluate them through a consistent authorization pipeline
And it integrates with any mediator-style pipeline and is NativeAOT compatible.
| Package | NuGet |
|---|---|
| Jameak.RequestAuthorization.Core | |
| Jameak.RequestAuthorization.Adapter.AspNetCore | |
| Jameak.RequestAuthorization.Adapter.Mediator | |
| Jameak.RequestAuthorization.Adapter.MediatR |
Need to integrate with a different mediator-style pipeline library? See this section for details on how to write your own adapter.
Click to see dependencies
- Jameak.RequestAuthorization.Core
- Microsoft.Extensions.DependencyInjection.Abstractions ≥ 10.0.0
- Jameak.RequestAuthorization.Adapter.AspNetCore
- Microsoft.AspNetCore.Authorization ≥ 10.0.0
- Microsoft.AspNetCore.Http.Abstractions ≥ 2.3.0
- Jameak.RequestAuthorization.Adapter.Mediator
- Mediator.Abstractions ≥ 3.0.1
- Jameak.RequestAuthorization.Adapter.MediatR
- MediatR ≥ 12.5.0
Authorization in this library consists of 3 parts:
- A requirement: A data object that describes an authorization rule.
- A requirement builder: Translates a request into a set of requirements that the caller must satisfy.
- A requirement handler: Evaluates a requirement.
We have an endpoint that lets us get the contents of a document:
The request type:
public record GetDocumentRequest(Guid DocumentId) : IRequest<GetDocumentResponse>;The response type:
public record GetDocumentResponse(string Content);The basic Mediator request handler:
public class GetDocumentRequestHandler : IRequestHandler<GetDocumentRequest, GetDocumentResponse>
{
private readonly IDocumentService _documentService;
public GetDocumentRequestHandler(IDocumentService documentService)
{
_documentService = documentService;
}
public async ValueTask<GetDocumentResponse> Handle(
GetDocumentRequest request,
CancellationToken cancellationToken)
{
var document = await _documentService.GetDocument(request.DocumentId);
return new GetDocumentResponse(document.Content);
}
}All of the above is standard Mediator code, where our request, response, and handler classes remain free of authorization concerns.
A requirement represents a single authorization rule. They are simple data objects that contain no logic.
public record HasReadAccessToDocumentRequirement(
Guid DocumentId,
Guid UserId)
: IRequestAuthorizationRequirement;Note
A requirement doesn't need to have any data or properties.
A requirement builder translates a request into one or more requirements. The builder produces requirements that the caller must satisfy but does not evaluate authorization.
Requirement builders must implement the IRequestAuthorizationRequirementBuilder<T> interface where T is the request-type that the builder creates requirements for.
public class GetDocumentRequestRequirementBuilder
: IRequestAuthorizationRequirementBuilder<GetDocumentRequest>
{
private readonly IUserAccessor _userAccessor;
public GetDocumentRequestRequirementBuilder(IUserAccessor userAccessor)
{
_userAccessor = userAccessor;
}
public Task<IRequestAuthorizationRequirement> BuildRequirementAsync(
GetDocumentRequest request,
CancellationToken token)
{
IRequestAuthorizationRequirement requirement = new HasReadAccessToDocumentRequirement(
request.DocumentId,
_userAccessor.CurrentUserId);
return Task.FromResult(requirement);
}
}A requirement handler evaluates a specific requirement. They contain authorization logic and return explicit success or failure.
Requirement handlers must inherit from the RequestAuthorizationHandlerBase<T> base-class where T is the handled requirement.
public class HasReadAccessToDocumentRequirementHandler
: RequestAuthorizationHandlerBase<HasReadAccessToDocumentRequirement>
{
private readonly IAuthService _authService;
public HasReadAccessToDocumentRequirementHandler(IAuthService authService)
{
_authService = authService;
}
public override async Task<RequestAuthorizationResult> CheckRequirementAsync(
HasReadAccessToDocumentRequirement requirement,
CancellationToken token)
{
if (await _authService.UserCanAccessDocument(
requirement.UserId,
requirement.DocumentId))
{
return RequestAuthorizationResult.Success(requirement);
}
return RequestAuthorizationResult.Fail(
requirement,
failureReason: "User is not allowed to access document");
}
}You will need to register...
- the core library services via
AddRequestAuthorizationCore - your requirement builder types via
AddRequirementBuilderType - your requirement handler types via
AddRequirementHandlerType - as well as the correct pipeline adapter for your mediator-library of choice.
builder.Services
.AddRequestAuthorizationCore()
.AddRequirementBuilderType<GetDocumentRequestRequirementBuilder, GetDocumentRequest>()
.AddRequirementHandlerType<HasReadAccessToDocumentRequirementHandler, HasReadAccessToDocument>();Alternatively, you can scan an assembly for requirement handlers and builders:
builder.Services
.AddRequestAuthorizationCore()
.AddRequirementBuilderTypesFromAssembly(typeof(Program).Assembly)
.AddRequirementHandlerTypesFromAssembly(typeof(Program).Assembly);Important
To register the authorization pipeline for your mediator-library of choice, see the Adapters section.
The library supports global requirements that are evaluated for every requests. Global requirement builders must implement the IGlobalRequestAuthorizationRequirementBuilder interface.
public class MustBeAuthenticatedRequirementBuilder : IGlobalRequestAuthorizationRequirementBuilder
{
public Task<IRequestAuthorizationRequirement> BuildRequirementAsync<TRequest>(
TRequest request,
CancellationToken token)
{
return Task.FromResult<IRequestAuthorizationRequirement>(new MustBeAuthenticatedRequirement());
}
}
Register the global builder like so:
builder.Services
.AddRequestAuthorizationCore()
.AddGlobalRequirementBuilderType<MustBeAuthenticatedRequirementBuilder>();Requirements can be composed, enabling more complex authorization checks. The library provides built-in composition primitives:
Require.All(logical AND)Require.Any(logical OR)
Evaluation is short-circuiting and preserves the evaluation tree, which makes complex authorization failures easy to diagnose.
You can compose requirements like so:
var authenticatedAndHasWriteScope = Require.All(
new AuthenticatedRequirement(),
new ScopeRequirement("orders:write"));
var managerOrAdminRequirement = Require.Any(
new RoleRequirement("Admin"),
new RoleRequirement("Manager"));Tip
To debug complex composed requirements, you can export a RequestAuthorizationResult to a Graphviz graph for visualization using the AuthorizationDiagnosticExporter-class.
The library evaluates requirements and produces a RequestAuthorizationResult. You can customize what happens when authorization succeeds or fails.
By default, when a requirement is not met, the default behavior is to throw an UnauthorizedException. You can change this by creating a class which implements the IUnauthorizedResultHandler interface. You may want to override the default unauthorized behavior if you need to throw a different exception type, or if you're using a discriminated union library to return strongly-typed errors.
When all requirements are met, the default behavior is to do nothing and pass the request along in the pipeline. You can change this by creating a class which implements the IAuthorizedResultHandler interface. This allows global side-effects to occur after authorization succeeds, such as logging or auditing where access to the successful RequestAuthorizationResult is desired. Note that this handler does not influence the response and should not throw to alter authorization outcomes.
To make the library use your custom handlers, add them like so during dependency injection registration:
builder.Services.AddRequestAuthorizationCore()
.WithAuthorizedResultHandler<CustomizedAuthorizedHandler>()
.WithUnauthorizedResultHandler<CustomizedUnauthorizedHandler>()Many applications have groups of requests that share common authorization rules. For example, multiple requests may operate on a customer, tenant, or organization, and therefore require the same authorization checks.
Instead of registering a requirement builder for every request type individually, you can register a single builder for a base request type and automatically apply it to all derived requests. This means that authorization logic for a group of related requests can live in one builder, and new request types will automatically receive the relevant authorization logic as long as they implement the base request interface.
This registration is performed using AddRequirementBuilderTypeForDerivedRequestsFromAssembly(...), which scans an assembly for request types that derive from the specified base request type and automatically registers the appropriate authorization builders.
Suppose multiple requests operate on a customer and expose a CustomerId. You can define a base request interface:
public interface ICustomerRequest<T> : IRequest<T>
{
Guid CustomerId { get; }
}A request can then implement this interface:
public class GetCustomerDataRequest : ICustomerRequest<GetCustomerDataResponse>
{
public Guid CustomerId { get; }
// Other fields necessary for this request
}
public class GetCustomerDataResponse
{
// Relevant fields for the response
}Instead of creating a requirement builder per request, you can define a single generic builder for the base request type:
public class CustomerRequestRequirementBuilder<T>
: IRequestAuthorizationRequirementBuilder<ICustomerRequest<T>>
{
public Task<IRequestAuthorizationRequirement> BuildRequirementAsync(
ICustomerRequest<T> request,
CancellationToken token)
{
IRequestAuthorizationRequirement requirement = // Your customer requirement
return Task.FromResult(requirement);
}
}Register the builder for all derived requests:
services.AddRequestAuthorizationCore()
.AddRequirementBuilderTypeForDerivedRequestsFromAssembly(
typeof(CustomerRequestRequirementBuilder<>),
typeof(ICustomerRequest<>),
typeof(Program).Assembly);The library will scan the assembly for request types deriving from ICustomerRequest<>, close the generic builder for each request, and register the corresponding closed builder type automatically.
Note
This feature requires assembly scanning and closes generic types at service registration time. Because of this it is not supported in NativeAOT scenarios.
To get the same benefits of a single requirement builder type without this scanning API, you need to register each builder manually as shown below:
services.AddRequestAuthorizationCore()
.AddRequirementBuilderType<CustomerRequestRequirementBuilder<GetCustomerDataResponse>, GetCustomerDataRequest>()
.AddRequirementBuilderType<CustomerRequestRequirementBuilder<SomeOtherCustomerResponse>, SomeOtherCustomerRequest>()The library contains several adapters to integrate the core framework-agnostic request authorization with your mediator-framework of choice.
To integrate with the Mediator.SourceGenerator library, add a reference to the Jameak.RequestAuthorization.Adapter.Mediator package and register the Mediator-pipeline adapters as shown below.
Note that the default service-lifetime of Mediator is Singleton while the default service-lifetime of this library is Scoped. You must decide which lifetime is correct for your usage and configure the same lifetime in both libraries. For example, to configure both as ServiceLifetime.Scoped:
// Configuring Mediator.SourceGenerator lifetime:
builder.Services.AddMediator(options =>
{
options.ServiceLifetime = ServiceLifetime.Scoped;
});
// Configuring lifetime for this library and registering the adapter:
builder.Services
.AddRequestAuthorizationCore(serviceLifetime: ServiceLifetime.Scoped)
.AddMediatorPipelineAdapter();Important
When using NativeAOT with Mediator, you must instead register the pipeline behaviors in the AddMediator(...) call as described in the Mediator documentation. See the xmldoc documentation on this class and the NativeAOT sample for more information.
To integrate with the MediatR library, add a reference to the Jameak.RequestAuthorization.Adapter.MediatR package and register the MediatR-pipeline adapters like so:
builder.Services.AddRequestAuthorizationCore()
.AddMediatRPipelineAdapter();If you're already using ASP.NET Core IAuthorizationRequirements or Policies to handle authorization in other parts of your application, you can plug these into the request authorization pipeline via the Jameak.RequestAuthorization.Adapter.AspNetCore package.
Register the adapters like so:
builder.Services.AddRequestAuthorizationCore()
.AddAspNetAdapter();And then use AspNetAuthorizationRequirement or AspNetAuthorizationPolicyRequirement to wrap your existing ASP.NET Core authorization logic.
Note
You must also register a IHttpContextAccessor in your dependency container. This is usually done via the standard builder.Services.AddHttpContextAccessor() ASP.NET Core extension method.
To integrate with another mediator library, simply wrap the core IAuthorizationPipelineStep inside a pipeline behavior appropriate for your framework. See the Mediator adapter and MediatR adapter implementations for examples.
Jameak.RequestAuthorization is designed:
- For application-layer request pipelines
- For CQRS / mediator-based architectures
- For fine-grained, strongly-typed, per-request authorization composition
- To be independent of transport (HTTP, messaging, tests, background workers)
ASP.NET Core authorization works well for HTTP endpoints with simple role-/claim-/policy-based requirements. However, for more complex resource-based authorization needs, ASP.NET Core requires you to explicitly invoke authorization via IAuthorizationService:
var result = await _authorizationService.AuthorizeAsync(user, resource, policy);
if (!result.Succeeded)
{
return Forbid();
}This approach has several downsides:
- Every handler must remember to call
AuthorizeAsync - Every handler must remember to check the result
- There is no framework-level enforcement that authorization was performed
Jameak.RequestAuthorization removes this risk by integrating authorization directly into the mediator pipeline. This ensures that:
- Authorization is a first-class part of request execution
- Authorization is enforced consistently for every request
- Every request processed by the pipeline has an associated authorization requirement