ASP.NET Core Authorization
Trellis.Asp.Authorization translates an authenticated ClaimsPrincipal into a frozen Actor (id + permissions + forbidden permissions + ABAC attributes) so handlers, mediator behaviors, and endpoints stop parsing JWT claims directly.
Patterns Index
| Goal | Use | See |
|---|---|---|
| Resolve actors from Azure Entra ID v2.0 tokens | AddEntraActorProvider(options?) |
Entra ID provider |
| Resolve actors from any flat-claim OIDC/JWT provider | AddClaimsActorProvider(options?) |
Generic claims provider |
| Inject a fake actor in Development / integration tests | AddDevelopmentActorProvider(options?) |
Development provider |
| Cache an async actor resolution per request | AddCachingActorProvider<T>() |
Caching wrapper |
| Flatten roles into permissions / add ABAC attributes | EntraActorOptions.MapPermissions / MapAttributes |
Customizing claim mapping |
| Read well-known attributes safely | Actor.GetAttribute(ActorAttributes.*) |
ABAC attributes |
| Enforce static permissions on a command | IAuthorize.RequiredPermissions |
Mediator integration |
| Authorize against a loaded entity | IAuthorizeResource<TResource> |
Mediator integration |
Use this guide when
- You host a Trellis service in ASP.NET Core and need to convert an authenticated principal into an
Actorexactly once per request. - You want one DI registration that gives every handler, behavior, and endpoint a consistent view of the caller.
- You want to customize how JWT claims map to permissions, deny lists, or ABAC attributes without forking the provider.
- You want development/integration-test seams that fail closed outside
IsDevelopment().
Surface at a glance
Trellis.Asp.Authorization (namespace inside the Trellis.Asp package) exposes one set of DI extensions plus four IActorProvider implementations and matching options classes.
| API | Kind | Returns / Lifetime | Purpose |
|---|---|---|---|
AddEntraActorProvider(this IServiceCollection, Action<EntraActorOptions>?) |
DI extension | Scoped IActorProvider → EntraActorProvider |
Entra v2.0 (oid/roles/tid/amr ...) → Actor. |
AddClaimsActorProvider(this IServiceCollection, Action<ClaimsActorOptions>?) |
DI extension | Scoped IActorProvider → ClaimsActorProvider |
Generic flat-claim mapping (configurable ActorIdClaim, PermissionsClaim). |
AddDevelopmentActorProvider(this IServiceCollection, Action<DevelopmentActorOptions>?) |
DI extension | Scoped IActorProvider → DevelopmentActorProvider |
Reads X-Test-Actor JSON header; throws outside IsDevelopment(). |
AddCachingActorProvider<T>(this IServiceCollection) |
DI extension | Scoped IActorProvider → CachingActorProvider wrapping T |
Caches one resolution task per request scope. |
ClaimsActorProvider |
Class | Scoped, virtual GetCurrentActorAsync |
Subclass for custom flat-claim providers. Permissions collected via FindAll(PermissionsClaim). |
EntraActorProvider |
Class | Scoped, sealed | Falls back to short oid when IdClaimType is the default; rewraps mapper exceptions in InvalidOperationException. |
DevelopmentActorProvider |
Class | Scoped, sealed partial | Logs a warning and falls back when the header is malformed (unless ThrowOnMalformedHeader = true). |
CachingActorProvider |
Class | Scoped, sealed | Uses LazyInitializer.EnsureInitialized + HttpContext.RequestAborted; honors per-call CancellationToken via Task.WaitAsync. |
EntraActorOptions / ClaimsActorOptions / DevelopmentActorOptions |
Options | — | Mapping delegates / claim-type strings / dev defaults. |
Full signatures: trellis-api-authorization.md.
Installation
dotnet add package Trellis.Asp
The actor providers ship in Trellis.Asp under namespace Trellis.Asp.Authorization. Domain primitives (Actor, IActorProvider, IAuthorize, IAuthorizeResource<T>) come from Trellis.Authorization.
Quick start
Authenticate with JwtBearer, register EntraActorProvider, then read the current Actor from any endpoint or handler.
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
using Trellis.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddEntraActorProvider();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/me", [Authorize] async (IActorProvider actorProvider, CancellationToken ct) =>
{
var actor = await actorProvider.GetCurrentActorAsync(ct);
return Results.Ok(new
{
actor.Id,
Permissions = actor.Permissions.OrderBy(p => p).ToArray(),
TenantId = actor.GetAttribute(ActorAttributes.TenantId),
Mfa = actor.GetAttribute(ActorAttributes.MfaAuthenticated),
});
});
app.Run();
Note
The provider extracts an Actor from HttpContext.User. It does not validate tokens — keep your normal authentication middleware in place.
Entra ID provider
| Member | Default | Override to... |
|---|---|---|
EntraActorOptions.IdClaimType |
"http://schemas.microsoft.com/identity/claims/objectidentifier" (falls back to short "oid") |
Use sub, employee ID, etc. |
EntraActorOptions.MapPermissions |
union of roles and ClaimTypes.Role claim values (case-insensitive type match) |
Flatten roles into fine-grained permissions; merge DB-sourced grants. |
EntraActorOptions.MapForbiddenPermissions |
empty HashSet<string> |
Project a deny-list claim or DB lookup. |
EntraActorOptions.MapAttributes |
extracts tid, preferred_username, azp, azpacr, acrs; adds ip_address from Connection.RemoteIpAddress and mfa = "true"\|"false" from any amr claim equal to "mfa" |
Add tenant-scoped or request-scoped attributes. |
EntraActorProvider throws InvalidOperationException when no HttpContext is available, no authenticated ClaimsIdentity exists, or the configured IdClaimType is missing (and the short oid fallback also misses). Any exception thrown by MapPermissions, MapForbiddenPermissions, or MapAttributes is rewrapped in InvalidOperationException naming the failing delegate.
Generic claims provider
For any flat-claim OIDC token where you can name the id and permissions claim types directly.
| Member | Default | Notes |
|---|---|---|
ClaimsActorOptions.ActorIdClaim |
"sub" |
Verbatim claim-type match — no JSON-path traversal. |
ClaimsActorOptions.PermissionsClaim |
"permissions" |
Multi-valued JWT claims arrive as repeated Claim instances and are aggregated via FindAll. |
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
builder.Services.AddClaimsActorProvider(options =>
{
options.ActorIdClaim = "sub";
options.PermissionsClaim = "permissions";
});
ClaimsActorProvider.GetCurrentActorAsync is virtual — subclass it to compute permissions from nested claims, look them up in a store, etc., then register your subclass via AddCachingActorProvider<TYourProvider>() to amortize the cost across the request.
Development provider
For local development and integration tests only. The provider reads an X-Test-Actor JSON header shaped like { "Id": "...", "Permissions": [...], "ForbiddenPermissions": [...], "Attributes": {...} } (case-insensitive property matching).
| Member | Default | Notes |
|---|---|---|
DevelopmentActorOptions.DefaultActorId |
"development" |
Used when the header is missing or empty. |
DevelopmentActorOptions.DefaultPermissions |
empty HashSet<string> |
Used when the header is missing or empty. |
DevelopmentActorOptions.ThrowOnMalformedHeader |
false |
When true, malformed JSON throws instead of logging a warning and falling back. |
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Trellis.Asp.Authorization;
if (builder.Environment.IsDevelopment())
{
builder.Services.AddDevelopmentActorProvider(options =>
{
options.DefaultActorId = "developer@local";
options.DefaultPermissions = new HashSet<string> { "Products.Read", "Products.Write" };
});
}
else
{
builder.Services.AddEntraActorProvider();
}
Warning
DevelopmentActorProvider.GetCurrentActorAsync throws InvalidOperationException whenever IHostEnvironment.IsDevelopment() is false — even when the header is absent. Registering it in Production is a fail-fast safety net.
For test clients, the WebApplicationFactoryExtensions.CreateClientWithActor(...) helper in Trellis.Testing.AspNetCore writes the same header for you; see trellis-api-testing-aspnetcore.md.
Caching wrapper
When resolving an actor requires extra async work (database lookups, remote calls), wrap the inner provider so a single resolution task is shared across the request scope.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
using Trellis.Authorization;
public interface IPermissionStore
{
Task<IReadOnlySet<string>> GetPermissionsAsync(string userId, CancellationToken ct);
}
public sealed class DatabaseActorProvider(
IHttpContextAccessor httpContextAccessor,
IPermissionStore permissionStore) : IActorProvider
{
public async Task<Actor> GetCurrentActorAsync(CancellationToken cancellationToken = default)
{
var userId = httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? throw new InvalidOperationException("Missing user id.");
var permissions = await permissionStore.GetPermissionsAsync(userId, cancellationToken);
return Actor.Create(userId, permissions);
}
}
builder.Services.AddSingleton<IPermissionStore, MyPermissionStore>();
builder.Services.AddCachingActorProvider<DatabaseActorProvider>();
CachingActorProvider issues the inner call with HttpContext.RequestAborted so the shared work is cancelled with the request, then forwards each caller's own CancellationToken via Task.WaitAsync.
Customizing claim mapping
EntraActorOptions exposes three independent delegates — override only what you need.
Flatten Entra roles into application permissions
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
builder.Services.AddEntraActorProvider(options =>
{
var rolePermissionMap = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["Catalog.Admin"] = ["Products.Read", "Products.Write", "Products.Delete"],
["Catalog.Reader"] = ["Products.Read"],
};
options.MapPermissions = claims => claims
.Where(c => string.Equals(c.Type, "roles", StringComparison.OrdinalIgnoreCase))
.SelectMany(c => rolePermissionMap.TryGetValue(c.Value, out var perms) ? perms : Array.Empty<string>())
.ToHashSet(StringComparer.Ordinal);
});
Use delegated scopes (scp) instead of roles
using System;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
builder.Services.AddEntraActorProvider(options =>
{
options.MapPermissions = claims => claims
.Where(c => string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase))
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
});
Add forbidden permissions and custom attributes
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
using Trellis.Authorization;
builder.Services.AddEntraActorProvider(options =>
{
options.MapForbiddenPermissions = claims => claims
.Where(c => string.Equals(c.Type, "denied_permissions", StringComparison.OrdinalIgnoreCase))
.Select(c => c.Value)
.ToHashSet(StringComparer.Ordinal);
options.MapAttributes = (claims, httpContext) =>
{
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
var region = claims.FirstOrDefault(c => c.Type == "region")?.Value;
if (!string.IsNullOrWhiteSpace(region))
attributes["region"] = region;
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrWhiteSpace(ip))
attributes[ActorAttributes.IpAddress] = ip;
return attributes;
};
});
Warning
Throwing inside any Map* delegate produces an InvalidOperationException with the message EntraActorOptions.<delegate> threw an exception while mapping the authenticated user's claims. — it does not silently default.
ABAC attributes
Actor.Attributes is an immutable FrozenDictionary<string, string>. Read it via GetAttribute (returns string?) or HasAttribute, and key it with ActorAttributes.* constants instead of magic strings.
using Trellis;
using Trellis.Authorization;
var tenantId = actor.GetAttribute(ActorAttributes.TenantId);
if (tenantId is null || !actor.HasPermission("Documents.Read", tenantId))
return Result.Fail(new Error.Forbidden("documents.read") { Detail = "Wrong tenant." });
var clientApp = actor.GetAttribute(ActorAttributes.AuthorizedParty);
var mfaPassed = actor.GetAttribute(ActorAttributes.MfaAuthenticated) == "true";
Actor.HasPermission(permission, scope) checks for the joined string permission:scope, where : is Actor.PermissionScopeSeparator. See trellis-api-authorization.md for the full deny-overrides-allow rules.
Mediator integration
When a request flows through Trellis.Mediator, prefer pipeline behaviors over per-handler permission checks — AuthorizationBehavior<TMessage, TResponse> (see trellis-api-mediator.md) calls IActorProvider.GetCurrentActorAsync once and short-circuits the pipeline with a typed Error.Forbidden.
Static permission checks via IAuthorize
using System.Collections.Generic;
using Mediator;
using Trellis;
using Trellis.Authorization;
public sealed record DeleteDocumentCommand(string DocumentId)
: ICommand<Result<Unit>>, IAuthorize
{
public IReadOnlyList<string> RequiredPermissions { get; } = ["Documents.Delete"];
}
The behavior requires the actor to hold every listed permission (AND semantics). Returning Result<Unit> from the command is the canonical command shape.
Resource-based checks via IAuthorizeResource<TResource>
Use this when the rule depends on a loaded entity, not just static permissions. Pair it with IIdentifyResource<TResource, TId> + a SharedResourceLoaderById<TResource, TId> so every command authorizing against the same resource type loads it the same way.
using Mediator;
using Trellis;
using Trellis.Authorization;
public sealed record Document(string Id, string OwnerId);
public sealed record EditDocumentCommand(string DocumentId)
: ICommand<Result<Unit>>, IAuthorizeResource<Document>, IIdentifyResource<Document, string>
{
public string GetResourceId() => DocumentId;
public IResult Authorize(Actor actor, Document resource) =>
Result.Ensure(
actor.IsOwner(resource.OwnerId) || actor.HasPermission("Documents.EditAny"),
new Error.Forbidden("documents.edit") { Detail = "Only the owner can edit this document." });
}
Wire the loader and pipeline behaviors in Program.cs:
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
using Trellis.Mediator;
builder.Services.AddEntraActorProvider();
// 1. Register the resource loader for this command.
// DocumentResourceLoader implements IResourceLoader<EditDocumentCommand, Document>.
builder.Services.AddScoped<IResourceLoader<EditDocumentCommand, Document>, DocumentResourceLoader>();
// 2. Register the resource-authorization pipeline behavior.
// Type arguments are <TMessage, TResource, TResponse> — the *command* type comes first,
// then the resource type loaded for it, then the command's response type.
builder.Services.AddResourceAuthorization<EditDocumentCommand, Document, Result<Unit>>();
builder.Services.AddMediator(options =>
{
options.ServiceLifetime = ServiceLifetime.Scoped;
options.PipelineBehaviors = [.. Trellis.Mediator.ServiceCollectionExtensions.PipelineBehaviors];
});
Note
AddResourceAuthorization lives in Trellis.Mediator. Forgetting using Trellis.Mediator; produces CS1061: 'IServiceCollection' does not contain a definition for 'AddResourceAuthorization'. The full registration shape is documented in trellis-api-mediator.md.
Composition
These providers compose three ways:
- Provider stack.
AddCachingActorProvider<TInner>()decorates anyIActorProvider— including aClaimsActorProvidersubclass you authored — without changing handler code. - Mediator pipeline. Once an
IActorProvideris registered, bothIAuthorize(static) andIAuthorizeResource<T>(resource) checks run insideAuthorizationBehaviorand fail with typedError.Forbidden. ASP integration (trellis-api-asp.md) maps that to RFC 7807403. - Result pipelines. Inside handlers,
Actorpredicates returnbool, soResult.Ensure(actor.HasPermission(...), new Error.Forbidden(...))plugs straight intoBind/Mapchains.
using System.Threading;
using System.Threading.Tasks;
using Trellis;
using Trellis.Authorization;
public sealed record Document(string Id, string OwnerId);
public interface IDocumentRepository
{
Task<Result<Document>> GetByIdAsync(string id, CancellationToken ct);
}
public static class DocumentService
{
public static async Task<Result<Document>> LoadForEditAsync(
IActorProvider actors,
IDocumentRepository repo,
string id,
CancellationToken ct)
{
var actor = await actors.GetCurrentActorAsync(ct);
return await repo.GetByIdAsync(id, ct)
.EnsureAsync(
doc => actor.IsOwner(doc.OwnerId) || actor.HasPermission("Documents.EditAny"),
new Error.Forbidden("documents.edit") { Detail = "Only the owner can edit this document." });
}
}
Practical guidance
- Never authorize on
preferred_username. It is for display and audit only and may change. - Use
ActorAttributesconstants. Guarantees consistent casing and survives renames. - Map roles to permissions once, in
MapPermissions. Keeps runtime checksO(1)against aFrozenSet<string>. - Prefer scoped permissions (
Documents.Read:tenant-123) andActor.HasPermission(name, scope)over a separate ABAC dictionary lookup. - Wrap expensive providers with
AddCachingActorProvider<T>()so re-checks during the same request stay cheap. - Keep
AddDevelopmentActorProviderbehindIsDevelopment(). The provider also fails closed, but registration discipline avoids accidental dependency onX-Test-Actorfrom staging tests. - Let mapper exceptions bubble. A buggy
MapPermissionsbecomesInvalidOperationException("EntraActorOptions.MapPermissions threw ..."); surfacing it during development reveals bad role tables faster than any silent fallback.
Cross-references
- Domain primitives (
Actor,IAuthorize,IAuthorizeResource<T>,ActorAttributes):trellis-api-authorization.md - ASP DI surface and other ASP integration helpers:
trellis-api-asp.md Result,Error.Forbidden,Error.NotFound:trellis-api-core.mdAuthorizationBehavior<TMessage, TResponse>andAddResourceAuthorization:trellis-api-mediator.md- Test client header helper (
CreateClientWithActor):trellis-api-testing-aspnetcore.md