Trellis.Authorization — API Reference
Package: Trellis.Authorization
Namespace: Trellis.Authorization
Purpose: Domain-layer authorization primitives — actor identity / permission / attribute model and the contracts used by the mediator's authorization behavior to perform static (permission) and resource-based authorization. This package contains no ASP.NET Core dependencies; the IActorProvider implementations and DI helpers ship in Trellis.Asp (see trellis-api-asp.md, namespace Trellis.Asp.Authorization).
Tip
For the end-to-end mental model — JWT → JwtBearer → IActorProvider → mediator behaviors → 401/403 — plus decision trees and Mermaid diagrams, read Mental model in the integration article first. This file is the type-by-type reference.
See also: trellis-api-cookbook.md — recipes using this package.
Use this file when
- You are modeling actors, permissions, forbidden permissions, or actor attributes without ASP.NET dependencies.
- You are implementing static permission authorization through
IAuthorize. - You are implementing resource-based authorization through
IAuthorizeResource<TResource>and want the canonical guard shape. - You need a required-actor accessor where actor presence and stable or cached provider resolution are already guaranteed.
- You need to distinguish provider lookups from the checked dispatch snapshot supplied by Mediator's actor-aware handler bases.
Owner check quick-start — copy this
The 90% case is "the command's resource id is loaded, and the actor must be the owner (or hold an admin permission)." For that shape, implement two interfaces — IAuthorizeResource<TResource> for the owner check and IIdentifyResource<TResource, TId> so the framework reuses the shared SharedResourceLoaderById<TResource, TId> instead of requiring a per-command loader.
using Trellis;
using Trellis.Authorization;
public sealed record CancelOrderCommand(OrderId OrderId)
: ICommand<Result<Unit>>,
IAuthorizeResource<Order>,
IIdentifyResource<Order, OrderId>
{
public OrderId GetResourceId() => OrderId;
public IResult Authorize(Actor actor, Order resource) =>
Result.Ensure(
resource.OwnerId == actor.Id || actor.HasPermission("orders:admin"),
() => new Error.Forbidden(
Code: "orders.owner",
Resource: ResourceRef.For<Order>(OrderId)));
}
// DI (composition root):
services.AddScoped<SharedResourceLoaderById<Order, OrderId>, OrderResourceLoader>();
services.AddSharedResourceAuthorization<CancelOrderCommand, Order, OrderId, Result<Unit>>();
// One per command — AOT-safe; or use the assembly-scanning overload.
That's it. The mediator pipeline:
- resolves the actor via the registered
IActorProvider, - loads the
OrderbyOrderIdvia yourOrderResourceLoaderimplementation ofSharedResourceLoaderById<Order, OrderId>and the adapter installed byAddSharedResourceAuthorization, - calls
Authorize(actor, order).
For multi-hop authorization (the resource the actor must own is reached via one or more navigation hops, including the cricket "actor owns home OR away team" shape), use IAuthorizeResourceVia<TOwner> with IIdentifyRelatedResource[s]<TRelated, TId> along the path — see cookbook Recipe 24.
AddSharedResourceAuthorization lives in Trellis.Mediator; import Microsoft.Extensions.DependencyInjection and Trellis.Mediator in the composition root. Its builder counterpart is UseSharedResourceAuthorization<TMessage,TResource,TId,TResponse>(). The existing AddResourceAuthorization<TMessage,TResource,TResponse>() remains the lower-level behavior/accessor registration and still requires a loader or AddSharedResourceLoader bridge separately.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Owner check on the command's resource (the 90% case) | Implement IAuthorizeResource<TResource> + IIdentifyResource<TResource, TId>; the framework loads via SharedResourceLoaderById<TResource, TId> and calls Authorize(actor, resource) |
Owner check quick-start |
| Represent the current user/service | Actor |
Actor |
| Check granted permissions with explicit deny override | actor.HasPermission(...), HasAllPermissions(...), HasAnyPermission(...) |
Actor |
| Resolve actor for a request/message | IActorProvider.GetCurrentActorAsync(...) |
IActorProvider |
| Receive the actor actually checked by this dispatch without another provider lookup | ActorCommandHandler, ActorQueryHandler, or the direct/via resource variants in Trellis.Mediator |
Mediator handler bases |
| Read a required actor through a stable or explicitly cached provider after authorization | actorProvider.RequireActorAsync(cancellationToken) |
ActorProviderExtensions |
| Require static permissions on a message | Implement IAuthorize.RequiredPermissions |
IAuthorize |
| Authorize against a loaded resource | Implement IAuthorizeResource<TResource>.Authorize(actor, resource) |
IAuthorizeResource<TResource> |
| Write owner/admin resource guards | Result.Ensure(condition, () => new Error.Forbidden(...)) |
IAuthorizeResource<TResource>, Core Result.Ensure |
| Identify a resource by id for shared loading | IIdentifyResource<TResource, TId> |
IIdentifyResource<TResource, TId> |
| Authorize against a related resource one or more navigation hops away (cricket-style fan-out, owner chains) | Implement IAuthorizeResourceVia<TOwner> on the command + IIdentifyRelatedResource<TRelated, TId> (singular) or IIdentifyRelatedResources<TRelated, TId> (terminal plural) on entities along the path |
IAuthorizeResourceVia<TOwner>, IIdentifyRelatedResource<TRelated, TId>, IIdentifyRelatedResources<TRelated, TId> |
Common traps
Trellis.Authorizationis domain/application-layer only. ASP.NET actor providers are documented in trellis-api-asp.md.- Prefer
Result.Ensurefor boolean authorization guards so generated code uses the same ROP primitive as the rest of Trellis. RequireActorAsyncis an invariant accessor, not an authentication gate. Keep ordinary missing-actor handling onGetCurrentActorAsync/Error.AuthenticationRequired; do not replace the authorization behaviors' normal 401 path with this throwing helper.- Standard Mediator authorization stages share one Actor reference per dispatch. Actor-aware handler bases expose that reference only after all declared gates pass;
RequireActorAsyncstill performs another provider lookup. A caching provider may span dispatches, whereas the framework snapshot does not. - Do not mutate
RequiredPermissions; expose the complete permission list as an immutable/read-only collection. - The DI registration extension
AddResourceAuthorization(...)lives inTrellis.Mediator(namespace Trellis.Mediator), not inTrellis.Authorization. Wiring anIAuthorizeResource<TResource>therefore typically requires bothusing Trellis.Authorization;(for the interfaces) andusing Trellis.Mediator;(for the DI extension). The compile error if the second is missing isCS1061: 'IServiceCollection' does not contain a definition for 'AddResourceAuthorization' and no accessible extension method 'AddResourceAuthorization' accepting a first argument of type 'IServiceCollection' could be found— see trellis-api-mediator.md.
Common identity-provider claim shapes
The default ClaimsActorProvider (in Trellis.Asp.Authorization) maps a flat claim name to Actor.Id and a flat claim name to Actor.Permissions. Identity providers that ship claims under a nested JSON object require either the NestedJsonPathClaimsActorProvider derivation or a custom override of ClaimsActorProvider.GetCurrentActorAsync. The table below covers the configurations that most consumers reach for; inspect your token to confirm the exact shape.
| Provider | Token shape | Recommended configuration |
|---|---|---|
| Auth0 (default JWT) | { "sub": "auth0\|abc", "permissions": ["orders:read"] } |
AddClaimsActorProvider(opts => { opts.ActorIdClaim = "sub"; opts.PermissionsClaim = "permissions"; }) |
Auth0 (app_metadata.roles) |
{ "sub": "auth0\|abc", "app_metadata": { "roles": ["orders:read"] } } |
AddNestedJsonPathClaimsActorProvider(opts => { opts.ActorIdClaim = "sub"; opts.ContainerClaim = "app_metadata"; opts.PermissionsPath = "roles"; }) |
| Azure B2C custom attribute | { "sub": "...", "extension_RolesJson": "{\"roles\":[\"orders:read\"]}" } |
AddNestedJsonPathClaimsActorProvider(opts => { opts.ActorIdClaim = "sub"; opts.ContainerClaim = "extension_RolesJson"; opts.PermissionsPath = "roles"; }) |
| Entra ID v2 (interactive user) | uses oid + multiple roles claims |
AddEntraActorProvider(opts => { ... }) — purpose-built for Entra; see the type reference |
Keycloak (realm_access.roles) |
{ "sub": "...", "realm_access": { "roles": ["orders:read"] } } |
AddNestedJsonPathClaimsActorProvider(opts => { opts.ActorIdClaim = "sub"; opts.ContainerClaim = "realm_access"; opts.PermissionsPath = "roles"; }) |
| Okta (nested custom claim) | { "sub": "...", "custom_claims": { "permissions": ["orders:read"] } } |
AddNestedJsonPathClaimsActorProvider(opts => { opts.ActorIdClaim = "sub"; opts.ContainerClaim = "custom_claims"; opts.PermissionsPath = "permissions"; }) |
Silent-403 diagnostics. When ClaimsActorProvider resolves zero permissions on an authenticated identity that does carry other claims, it emits one warning per application lifetime (EventId 2). When the configured PermissionsClaim resolves to a single value that parses as a JSON object or array, it emits one error per application lifetime (EventId 3) recommending NestedJsonPathClaimsActorProvider. Silent-401 diagnostic. When the configured ActorIdClaim (and its short↔long fallback) resolves nothing on an authenticated identity that carries other claims, it emits one warning per application lifetime (EventId 5) naming the configured claim and the claim types actually present. Set ClaimsActorOptions.ValidateClaimShapeOnFirstUse = false to suppress all three. Custom providers deriving from ClaimsActorProvider that pass logger: null to the base constructor (because they own a logger of their own category, as NestedJsonPathClaimsActorProvider does) should override protected virtual ILogger? DiagnosticLogger to return their own logger; otherwise diagnostics raised on resolution paths that delegate back to the base are silently dropped.
Startup validation of NestedJsonPathClaimsActorOptions. AddNestedJsonPathClaimsActorProvider(...) registers an IValidateOptions<NestedJsonPathClaimsActorOptions> with ValidateOnStart(). If ActorIdPath or PermissionsPath is configured without a non-blank ContainerClaim (empty or whitespace-only both count as missing), host startup fails with OptionsValidationException — much earlier than the first-request InvalidOperationException that the provider's constructor would otherwise produce. The constructor guard is kept as defense-in-depth for manual (non-DI) construction. Two caveats:
- The validator is permissive when the options are at defaults (paths empty) so a registered-but-unconfigured provider doesn't block startup.
ValidateOnStart()fires regardless of whether a laterAddXxxActorProvidercall replacesIActorProvider. A consumer that callsAddNestedJsonPathClaimsActorProvider(opts => { ... invalid ... })and thenAddClaimsActorProvider()will still see startup fail on the nested options. Pick one provider per host; if you must replace one already-registered, remove the earlier call rather than chaining.
Types
Namespace Trellis.Authorization
Actor
Declaration
public sealed class Actor : IEquatable<Actor>
Actor is an authorization-layer entity. Equality / GetHashCode / == / != are based on the Id property only — two Actor instances with the same Id represent the same principal even when their Permissions, ForbiddenPermissions, or Attributes differ (those are point-in-time state about the principal, not part of identity). Mirrors Trellis.Entity<TId> semantics without inheriting the full IAggregate surface.
Constructors
| Signature | Description |
|---|---|
public Actor(ActorId id, IReadOnlySet<string> permissions, IReadOnlySet<string> forbiddenPermissions, IReadOnlyDictionary<string, string> attributes) |
Typed constructor. Snapshots the supplied state into frozen collections (ordinal comparison). Throws ArgumentNullException when any argument is null. |
public Actor(string id, IReadOnlySet<string> permissions, IReadOnlySet<string> forbiddenPermissions, IReadOnlyDictionary<string, string> attributes) |
Convenience overload that wraps the raw claim string in ActorId (via ActorId.Create) and delegates to the typed constructor. Throws ArgumentException when id is null / empty / whitespace. |
Fields
| Name | Type | Description |
|---|---|---|
PermissionScopeSeparator |
const char |
Separator used between permission name and scope in scoped permission strings. Value: ':'. |
Properties
| Name | Type | Description |
|---|---|---|
Id |
ActorId |
Strongly-typed principal identifier (e.g. JWT sub). Wraps the raw claim string in a RequiredString<ActorId> so the identity flows through authorization-layer APIs and consumer aggregate fields as a domain type rather than an untyped string. |
Permissions |
IReadOnlySet<string> |
Granted permissions. Ordinal comparison; setter snapshots into a FrozenSet<string>. |
ForbiddenPermissions |
IReadOnlySet<string> |
Explicit deny-list. Deny always overrides allow. Snapshotted into a FrozenSet<string>. |
Attributes |
IReadOnlyDictionary<string, string> |
ABAC attributes. Snapshotted into a FrozenDictionary<string, string> with ordinal comparer. |
Methods
| Signature | Returns | Description |
|---|---|---|
public static Actor Create(ActorId id, IReadOnlySet<string> permissions) |
Actor |
Typed factory. Creates an actor with empty ForbiddenPermissions and empty Attributes. Throws ArgumentNullException for null arguments. |
public static Actor Create(string id, IReadOnlySet<string> permissions) |
Actor |
Convenience factory that wraps the raw claim string in ActorId. |
public bool HasPermission(string permission) |
bool |
Returns true only when permission is in Permissions and not in ForbiddenPermissions. |
public bool HasPermission(string permission, string scope) |
bool |
Checks the scoped permission composed as permission + ':' + scope (deny-aware). Throws ArgumentNullException when either argument is null. |
public bool HasAllPermissions(IEnumerable<string> permissions) |
bool |
true when every entry passes HasPermission. |
public bool HasAnyPermission(IEnumerable<string> permissions) |
bool |
true when at least one entry passes HasPermission. |
public bool IsOwner(ActorId resourceOwnerId) |
bool |
Typed equality check between Id and resourceOwnerId using ActorId's value-equality semantics. Prefer this overload when the owner id is itself a typed ActorId so the comparison is type-checked at the call site. |
public bool IsOwner(string resourceOwnerId) |
bool |
Convenience overload that compares Id.Value and resourceOwnerId with StringComparison.Ordinal. |
public bool HasAttribute(string key) |
bool |
true when Attributes contains key. |
public string? GetAttribute(string key) |
string? |
Returns the attribute value or null when absent. |
public Result<TVo> GetRequiredAttribute<TVo>(string key) where TVo : class, IParsable<TVo> |
Result<TVo> |
Parses the attribute through the value object's IParsable implementation (any source-generated Required* VO — string-, Guid-, int-backed, and so on), validating via its TryParse (which routes through TryCreate): a success Result with the typed value when present and valid, otherwise a failed Result with an Error.InvalidInput whose field is key (a missing attribute fails the same way). Throws ArgumentNullException when key is null. |
public bool TryGetAttribute<TVo>(string key, out TVo? value) where TVo : class, IParsable<TVo> |
bool |
true with the parsed value when the attribute is present and valid; false (and value is null) when absent or invalid — deny-closes naturally in an authorization gate. Parses through the VO's IParsable, so any backing primitive works. Throws ArgumentNullException when key is null. |
ActorId
Declaration
[Trim, NotDefault]
public sealed partial class ActorId : RequiredString<ActorId>;
Strongly-typed wrapper around the raw principal id (typically the JWT sub or AAD oid claim) so the authorization layer exposes a domain type instead of an untyped string. Its explicit [Trim, NotDefault] attributes trim input and reject empty / whitespace-only ids; these are opt-ins, not RequiredString<T> defaults (the unannotated base rejects null but allows empty/whitespace). Generated factories ActorId.Create(string) / ActorId.TryCreate(string?) come from the bundled source generator (see trellis-api-core.md).
Consumers that store the principal id at aggregate boundaries — audit-style fields like Order.CreatedByActorId or Document.LastModifiedByActorId — should reuse ActorId for those fields so cross-aggregate comparisons (actor.IsOwner(order.CreatedByActorId)) are type-checked end-to-end. Domain identifiers that are conceptually different from the principal id (a customer aggregate id, a tenant member id, a domain user aggregate's primary key) remain whatever VO the domain models and are resolved to / from the principal at the application service boundary.
ActorAttributes
Declaration
public static class ActorAttributes
Well-known string keys for Actor.Attributes. Claim-derived keys align with Azure Entra ID v2.0 access tokens.
Fields
| Name | Type | Description |
|---|---|---|
TenantId |
const string |
Entra tid claim — issuing tenant GUID. Value: "tid". |
PreferredUsername |
const string |
Entra preferred_username claim. Value: "preferred_username". |
AuthorizedParty |
const string |
Entra azp claim — application ID of the calling client. Value: "azp". |
AuthorizedPartyAcr |
const string |
Entra azpacr claim — client authentication strength (0 public, 1 secret, 2 certificate). Value: "azpacr". |
AuthContextClassReference |
const string |
Entra acrs claim — Conditional Access auth-context references. Value: "acrs". |
IpAddress |
const string |
Request IP address, populated from HttpContext.Connection.RemoteIpAddress. Value: "ip_address". |
MfaAuthenticated |
const string |
Whether the actor authenticated with MFA — derived from the amr claim. Value: "mfa". |
IActorProvider
Declaration
public interface IActorProvider
| Signature | Returns | Description |
|---|---|---|
Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Returns the current authenticated actor wrapped in Maybe.From(...), or Maybe<Actor>.None when the request has no usable authenticated actor. The mediator authorization pipeline maps Maybe.None to Error.AuthenticationRequired (HTTP 401) per RFC 9110 §15.5.2. Implementations should throw InvalidOperationException only for genuine infrastructure or configuration failures (no HttpContext, mapping delegate threw, etc.) — those still surface as Error.Unexpected (HTTP 500). "No actor" is client-error state, not an exception. Register as scoped. |
401 vs 500 contract. Maybe<Actor>.None means the framework cannot identify an actor for the request — typically the request lacks credentials, the auth middleware did not produce an authenticated identity, or the configured actor-id claim is missing from an otherwise authenticated identity. All three are classes of client error and the mediator pipeline emits HTTP 401. A thrown InvalidOperationException means the provider itself cannot operate (no HttpContext, malformed configuration, mapping delegate failure) — that's a server bug and surfaces as HTTP 500.
WWW-Authenticateheader on the 401. RFC 9110 §11.6.1 requiresWWW-Authenticateon 401 responses.Error.AuthenticationRequiredstays transport-neutral, soResponseFailureWriteremits the optionalSchemestring verbatim when the error provides one; otherwise it synthesizes a scheme-level challenge from the registered default-challenge scheme viaIAuthenticationSchemeProviderwhen no other middleware has already written the header. The synthesized header uses the scheme NAME registered withAddAuthentication(soAddJwtBearer("ApiJwt", ...)producesWWW-Authenticate: ApiJwt). If no authentication is registered at all, the writer emits no synthesized header — synthesizing "Bearer" for a service that does not use Bearer would mislead clients. Applications that need a parameterized challenge can returnnew Error.AuthenticationRequired("Bearer realm=\"api\"")or writeWWW-Authenticatebefore Trellis renders the failure.
ActorProviderExtensions
public static class ActorProviderExtensions
| Signature | Returns | Description |
|---|---|---|
public static Task<Actor> RequireActorAsync(this IActorProvider actorProvider, CancellationToken cancellationToken = default) |
Task<Actor> |
Performs a new GetCurrentActorAsync call with the supplied token and returns that call's actor instance, not a previously authorized snapshot. Requires established presence and stable or explicitly cached provider resolution. Throws InvalidOperationException when absent, and ArgumentNullException for a null actorProvider. Provider exceptions and cancellation propagate unchanged. |
Use only when the caller has already established actor presence and stable provider
resolution. A successful authorization behavior checks one actor snapshot; it does not
guarantee that a later provider lookup returns the same identity or authorization state
(Permissions, ForbiddenPermissions, and Attributes). An uncached provider that queries
mutable permission data can return a different snapshot even when Id is unchanged.
When resolution can change, configure the existing scoped CachingActorProvider before
authorization runs, and use the same scoped IActorProvider for both authorization and
the handler. Caching only inside the handler, resolving the uncached inner provider directly,
or using a different scope does not preserve the earlier snapshot. A provider with an
explicit stability guarantee for the whole operation is also valid. The accessor itself
cannot verify this precondition.
Implementing an authorization marker alone is not enough if the behavior is not registered or the handler is called directly. HTTP authentication alone also does not guarantee usable actor claim mapping, so a direct endpoint must still handle ordinary absence as authentication failure.
using Trellis.Authorization;
// After authorization, using the same stable or request-cached provider.
Actor actor = await actorProvider.RequireActorAsync(cancellationToken);
This method performs no authentication or permission checks and introduces no cache,
accessor service, DI registration, or pipeline change. With the existing scoped
CachingActorProvider (from Trellis.Asp.Authorization), repeated calls reuse that provider's
resolution task; without it, stable identity and authorization state are the provider's
responsibility. Normal missing-actor 401 and insufficient-permission 403 behavior is unchanged.
An absent actor at this explicit
invariant boundary is a programming/configuration fault, not a replacement 401 result.
IAuthorizationMessage
public interface IAuthorizationMessage;
Common capability inherited by static, direct, and via authorization messages. It lets the Mediator dispatch context and actor-aware handler bases identify authorization participation without adding another interface to existing messages. The marker alone neither authenticates nor authorizes; the concrete authorization contracts still declare the required checks.
IResourceAuthorizationMessage
public interface IResourceAuthorizationMessage : IAuthorizationMessage;
Capability inherited by both resource authorization contracts. It requires a
resource-authorization gate in the dispatch context, but does not require static
IAuthorize permissions. Resource-only existence-hiding messages remain supported.
IAuthorize
Declaration
public interface IAuthorize : IAuthorizationMessage
Marker for commands/queries enforcing static (permission-only) authorization. The mediator's AuthorizationBehavior<TMessage, TResponse> requires the current actor to hold all listed permissions (AND semantics).
| Name | Type | Description |
|---|---|---|
RequiredPermissions |
IReadOnlyList<string> |
Permissions every caller must hold. Duplicates and order are ignored — the check is set-like under AND-semantics. |
IAuthorizeResource<TResource>
Declaration
public interface IAuthorizeResource<in TResource> : IResourceAuthorizationMessage
Implemented by a command/query to perform resource-based authorization once the resource has been loaded.
| Signature | Returns | Description |
|---|---|---|
IResult Authorize(Actor actor, TResource resource) |
IResult |
Returns success to proceed or a failure (typically Error.Forbidden) to short-circuit the pipeline. |
IIdentifyResource<TResource, TId>
Declaration
public interface IIdentifyResource<TResource, out TId>
Companion to IAuthorizeResource<TResource> that exposes a typed resource identifier so the pipeline can use a SharedResourceLoaderById<TResource, TId> instead of a per-command loader.
| Signature | Returns | Description |
|---|---|---|
TId GetResourceId() |
TId |
Extracts the typed resource ID from the message. |
IAuthorizeResourceVia<TOwner>
Declaration
public interface IAuthorizeResourceVia<TOwner> : IResourceAuthorizationMessage
Declares resource-based authorization against a resource that is not the leaf the command identifies, but is reachable via one or more IIdentifyRelatedResource[s]<,> declarations on entities along the navigation chain. The originating motivation is the cricket-style "actor owns Team1 OR Team2" pattern: command identifies a Match, authorization is evaluated against the set of teams it points at.
The pipeline always passes IReadOnlyList<TOwner> to Authorize — size 1 for chains terminating in a singular hop, size N for chains terminating in a plural hop (cricket fan-out). The framework does not impose the operator over the list; the command picks Any, All, or any other shape inside Authorize.
| Signature | Returns | Description |
|---|---|---|
IResult Authorize(Actor actor, IReadOnlyList<TOwner> owners) |
IResult |
Returns success to proceed or a failure (typically Error.Forbidden) to short-circuit. The owners list is non-null and non-empty; an empty plural navigation short-circuits to Error.Forbidden before Authorize is called. |
Failure semantics:
- Leaf load failure — propagates under the default
Propagatepolicy.HideAsNotFoundnormalizes root NotFound/Gone/Forbidden/AuthenticationRequired to the configured public NotFound; other leaf errors are unchanged. - Intermediate or owner load failure — collapsed to
Error.Forbiddento avoid leaking existence of related resources whose presence/absence the actor may not be authorized to learn. - Empty result at any hop (singular extract returning 0 IDs or plural extract returning 0 IDs) — short-circuits to
Error.Forbiddenwithout invokingAuthorize. - Missing
SharedResourceLoaderById<TTo, TToId>at any hop — throwsInvalidOperationException(deployment bug, not authorization denial).
Exposure policy is configured in ResourceAuthorizationOptions
and keyed on the leaf, not the owner. See Recipe 32.
A command may implement either IAuthorizeResource<T> or IAuthorizeResourceVia<TOwner>, never both. Registration throws at startup if both are present — security primitives are not silently composed.
IIdentifyRelatedResource<TRelated, TId>
Declaration
public interface IIdentifyRelatedResource<TRelated, out TId>
Entity-side declaration of a single outbound foreign-key navigation. Used by the resolver to walk the navigation chain at registration time. Implement on aggregate roots whose authorization is evaluated against a different aggregate one or more hops away.
The reflection path resolver rejects Nullable<T> identifier types on selected hops (for example, IIdentifyRelatedResource<Team, Guid?>). Nullable reference-type IDs can represent absence; for optional value-type IDs, expose a plural terminal navigation with zero or one non-nullable ID, supply an explicit ResolvedAuthorizationPath, or use a custom projection loader. A nullable property alone does not make a nullable value-type hop supported.
| Signature | Returns | Description |
|---|---|---|
TId GetRelatedResourceId() |
TId |
Returns the identifier of the related resource of type TRelated. |
For two distinct outbound navigations to the same TRelated (cricket Match.HomeTeamId / Match.AwayTeamId), use IIdentifyRelatedResources<TRelated, TId> instead — C# disallows declaring the same generic interface twice on a single type.
IIdentifyRelatedResources<TRelated, TId>
Declaration
public interface IIdentifyRelatedResources<TRelated, TId>
Entity-side declaration of a plural outbound navigation (fan-out). Used at the terminal hop of an authorization chain to express OR-style / candidate-set authorization (cricket Match → {HomeTeam, AwayTeam}).
| Signature | Returns | Description |
|---|---|---|
IReadOnlyList<TId> GetRelatedResourceIds() |
IReadOnlyList<TId> |
Returns the identifiers of all related resources of type TRelated. Non-null. Duplicates are de-duplicated by the pipeline before loading; an empty list short-circuits to Error.Forbidden. Order is not significant. |
Plural-in-middle (fan-out cartesian expansion) is intentionally out of scope for v1 and rejected at registration time. For chains needing a plural intermediate hop, drop to IResourceLoader<TMessage, TProjection> with a projection type.
IResourceLoader<TMessage, TResource>
Declaration
public interface IResourceLoader<in TMessage, TResource>
Loads the resource required for resource-based authorization. Resolved per request from DI as scoped.
| Signature | Returns | Description |
|---|---|---|
Task<Result<TResource>> LoadAsync(TMessage message, CancellationToken cancellationToken) |
Task<Result<TResource>> |
Returns the loaded resource or a failure (typically Error.NotFound). The pipeline short-circuits on failure before invoking IAuthorizeResource<TResource>.Authorize. |
ResourceLoaderById<TMessage, TResource, TId>
Declaration
public abstract class ResourceLoaderById<TMessage, TResource, TId> : IResourceLoader<TMessage, TResource>
Convenience base for loaders that extract an ID from the message and call a repository.
| Signature | Returns | Description |
|---|---|---|
protected abstract TId GetId(TMessage message) |
TId |
Extract the resource ID from the message. |
protected abstract Task<Result<TResource>> GetByIdAsync(TId id, CancellationToken cancellationToken) |
Task<Result<TResource>> |
Fetch the resource by ID; return Result.Fail with Error.NotFound when missing. |
public Task<Result<TResource>> LoadAsync(TMessage message, CancellationToken cancellationToken) |
Task<Result<TResource>> |
Sealed glue: calls GetId(message) then GetByIdAsync(...). |
SharedResourceLoaderById<TResource, TId>
Declaration
public abstract class SharedResourceLoaderById<TResource, TId>
A single loader shared across every command that authorizes against the same TResource. For a command implementing both IAuthorizeResource<TResource> and IIdentifyResource<TResource, TId>, use AddSharedResourceAuthorization<TMessage,TResource,TId,TResponse>() to register the behavior, accessor, and bridge without scanning. Alternatively, the assembly scanner discovers the bridge, or AddSharedResourceLoader registers it separately. Existing explicit IResourceLoader<TMessage, TResource> registrations are preserved.
The assembly-scanning overload of Trellis.Mediator.ServiceCollectionExtensions.AddResourceAuthorization(...) registers discovered concrete SharedResourceLoaderById<TResource, TId> implementations as scoped — safe to depend on a DbContext or other scoped repository. Explicit typed helpers do not register that implementation. Replace the registration after the scan completes if a different lifetime is required.
For per-message loader selection, both AddResourceAuthorization(assemblies) and AddResourceLoaders(assembly) let a discovered custom IResourceLoader<TMessage,TResource> replace a framework shared-loader adapter, even when the adapter was registered first. Application-provided loader implementations, factories, instances, and keyed registrations are preserved.
| Signature | Returns | Description |
|---|---|---|
public abstract Task<Result<TResource>> GetByIdAsync(TId id, CancellationToken cancellationToken) |
Task<Result<TResource>> |
Load the resource by ID; return Result.Fail with Error.NotFound when missing. |
IAuthorizedResource<TMessage, TResource>
Declaration
namespace Trellis.Authorization;
public interface IAuthorizedResource<TMessage, TResource>
where TResource : class
{
TResource GetRequiredResource();
bool TryGetResource([MaybeNullWhen(false)] out TResource resource);
}
Purpose. v4 typed accessor that gives a handler the same instance of the resource that ResourceAuthorizationBehavior (or ResourceAuthorizationViaBehavior) loaded for the current dispatch — eliminating a duplicate load when the handler would otherwise re-fetch by id from its repository. For non-EF stores (CosmosDB / Dapper / HTTP-backed loaders) this is a measurable performance win; for EF it skips the second LINQ query while preserving change-tracker identity.
Auto-registration. Every public entry that registers resource authorization also registers the closed IAuthorizedResource<TMessage, TResource> as scoped. Handlers inject it via constructor injection; no additional composition-root call is required.
- For
IAuthorizeResource<TResource>commands:TResourceis the sameTResource. - For
IAuthorizeResourceVia<TOwner>commands:TResourceis the leaf (the resource identified viaIIdentifyResource<TLeaf, TLeafId>), which is the typical mutation target. The owner accessor is intentionally not exposed in v4; handlers needing owner state read it from their repository.
| Signature | Returns | Description |
|---|---|---|
TResource GetRequiredResource() |
TResource |
Returns the resource loaded and authorized for the current pipeline dispatch. Throws InvalidOperationException outside a populated dispatch — typical causes: the handler was invoked directly (e.g. from a unit test) without going through the mediator pipeline; the message lacks resource-authorization registration; authentication failed, the loader failed, or Authorize was denied (none of which populate the accessor). The exception message names the closed pair so misconfiguration is easy to diagnose. |
bool TryGetResource(out TResource? resource) |
bool |
Returns true with the resource when populated; false and null otherwise. Provided for genuinely optional reads (e.g., diagnostic logging that runs both inside and outside the pipeline). Production handlers should prefer GetRequiredResource() so a misconfigured pipeline fails loudly instead of silently skipping work. |
Identity, not mutation-readiness. The accessor returns the SAME instance the loader returned. The framework does not validate that the instance is a canonical mutation-ready aggregate. Do not inject the accessor for commands whose loader returns:
- A projection type (e.g.
OrderHeaderfor cheap authorization, not the fullOrderaggregate). - A no-tracking EF entity that the handler must mutate (the mutation will not persist).
- A stale read-replica POCO when the handler needs strong consistency.
- An HTTP DTO that cannot be persisted back through any local repository.
In those cases the handler reloads via the repository. The loader's job is to decide who owns the resource for the authorization check; the handler's job is to fetch the canonical mutation-ready aggregate. These are different shapes and the accessor would couple them incorrectly.
Concurrency. Safe across nested mediator.Send and concurrent Task.WhenAll dispatch of the same closed pair within one DI scope. Implementation uses a per-async-flow linked frame list with a volatile IsActive flag — each push allocates a new frame (no shared mutable state between sibling forks), and dispose flips the frame's IsActive flag (visible to orphan child tasks that captured the frame at fork time but outlived the parent dispatch). The framework guarantees an orphan task cannot read the resource after the parent's dispatch ends.
See Recipe 31 in the cookbook for the WRONG/FIX shape and a full end-to-end example.
Behavioral notes
- Deny overrides allow. A permission listed in both
PermissionsandForbiddenPermissionsis denied.HasPermission,HasAllPermissions,HasAnyPermission, andHasPermission(permission, scope)all observe this rule. - Ordinal comparison everywhere. Permission lookups, attribute lookups, and
IsOwneruseStringComparison.Ordinal. Hydrate permissions and attributes with consistent casing. - Permissions snapshot to frozen collections. Mutating a collection passed into
Actorafter construction has no effect; the actor exposes aFrozenSet<string>/FrozenDictionary<string, string>snapshot for O(1) lookups. - Scoped permissions use the
"Permission:Scope"convention (Document.Edit:Tenant_A). Add scoped entries directly toPermissionsand check viaHasPermission(string, string)— no separate scope collection. - Exact match only — no check-time wildcards.
HasPermissionis a set lookup, so a granted"seasons:*"does not match aHasPermission("seasons:read")check, and a granted unscoped"seasons"does not satisfyHasPermission("seasons", scope). Expand anyentity:*-style authoring shorthand into a concrete permission catalog when hydrating theActor(e.g. a staticPermissionsconstants class plus a role→permissions map), consistent with the pre-flatten guidance above.Trellis.AspshipsRolePermissionProjectionto perform that role→permissions flattening from an app-supplied map. This keeps every check O(1) and avoids the privilege-escalation surface of pattern matching at authorization time. - Pipeline ordering. When a command implements both
IAuthorize(static) andIAuthorizeResource<TResource>(resource), the mediator behavior runs static checks first; resource loading andAuthorize(actor, resource)only execute if the static check passes. A loader failure short-circuits beforeAuthorizeis called.
Code examples
Static permission authorization
using Trellis;
using Trellis.Authorization;
public sealed partial class OrderId : RequiredGuid<OrderId>;
public sealed record DeleteOrderCommand(OrderId OrderId) : ICommand<Result<Unit>>, IAuthorize
{
public IReadOnlyList<string> RequiredPermissions { get; } = ["orders:delete"];
}
Resource-based authorization with a shared loader
Preferred in generated services. Use
IIdentifyResource<TResource, TId>+SharedResourceLoaderById<TResource, TId>for resource authorization. A per-commandIResourceLoader<TMessage, TResource>is an escape hatch for request-scoped state or command-specific load logic.
using Trellis;
using Trellis.Authorization;
public sealed partial class OrderId : RequiredGuid<OrderId>;
public sealed record Order(OrderId Id, ActorId OwnerId);
public sealed record CancelOrderCommand(OrderId OrderId)
: ICommand<Result<Unit>>, IAuthorizeResource<Order>, IIdentifyResource<Order, OrderId>
{
public OrderId GetResourceId() => OrderId;
public IResult Authorize(Actor actor, Order order) =>
Result.Ensure(
order.OwnerId == actor.Id || actor.HasPermission("orders:cancel-any"),
() => new Error.Forbidden("orders.cancel")
{ Detail = "Only the owner can cancel this order." });
}
public interface IOrderRepository
{
Task<Maybe<Order>> FindByIdAsync(OrderId id, CancellationToken ct);
}
public sealed class OrderResourceLoader(IOrderRepository repo)
: SharedResourceLoaderById<Order, OrderId>
{
public override Task<Result<Order>> GetByIdAsync(OrderId id, CancellationToken ct) =>
repo.FindByIdAsync(id, ct).ToResultAsync(() => new Error.NotFound(ResourceRef.For<Order>(id)));
}
Indirect (multi-hop) resource authorization — cricket fan-out
Authorize a UploadScorecardCommand against the actor owning either the home or away team of the match. The command identifies the leaf (Match); Match declares its plural outbound navigation to Team; the command's Authorize runs against the loaded list.
using Trellis;
using Trellis.Authorization;
public sealed partial class MatchId : RequiredGuid<MatchId>;
public sealed partial class TeamId : RequiredGuid<TeamId>;
public sealed class Match : Aggregate<MatchId>, IIdentifyRelatedResources<Team, TeamId>
{
public TeamId HomeTeamId { get; }
public TeamId AwayTeamId { get; }
public IReadOnlyList<TeamId> GetRelatedResourceIds() => [HomeTeamId, AwayTeamId];
}
public sealed class Team : Aggregate<TeamId>
{
public ActorId CreatedByActorId { get; }
}
public sealed record UploadScorecardCommand(MatchId MatchId, /* fields */)
: ICommand<Result<Unit>>,
IAuthorizeResourceVia<Team>,
IIdentifyResource<Match, MatchId>
{
public MatchId GetResourceId() => MatchId;
public IResult Authorize(Actor actor, IReadOnlyList<Team> owners) =>
Result.Ensure(
owners.Any(t => t.CreatedByActorId == actor.Id),
() => new Error.Forbidden("match.upload-scorecard")
{ Detail = "Actor does not own either match team." });
}
// Composition root: assembly scan registers the via-behavior, the leaf-loader bridge,
// and the SharedResourceLoaderById<Match,MatchId> + SharedResourceLoaderById<Team,TeamId>.
services.AddTrellisBehaviors();
services.AddResourceAuthorization(typeof(UploadScorecardCommand).Assembly);
For chains (e.g. Match → Team → Tournament), declare IIdentifyRelatedResource<Team, TeamId> on Match and IIdentifyRelatedResource<Tournament, TournamentId> on Team, then set IAuthorizeResourceVia<Tournament> on the command.
For shapes the navigation-chain model can't express (composite keys, conditional/data-dependent paths, recursive hierarchies, projections, joins, plural-in-middle), drop to an explicit IResourceLoader<TMessage, TProjection> returning a custom projection, and put IAuthorizeResource<TProjection> on the command.
Constructing an Actor directly (tests, custom providers)
using System.Collections.Generic;
using Trellis.Authorization;
var actor = new Actor(
id: "user-1",
permissions: new HashSet<string>
{
"orders:cancel",
$"orders:view{Actor.PermissionScopeSeparator}tenant-1",
},
forbiddenPermissions: new HashSet<string>(),
attributes: new Dictionary<string, string>
{
[ActorAttributes.TenantId] = "tenant-1",
[ActorAttributes.MfaAuthenticated] = "true",
});
bool canCancel = actor.HasPermission("orders:cancel");
bool canViewTenant = actor.HasPermission("orders:view", "tenant-1");
string? tenant = actor.GetAttribute(ActorAttributes.TenantId);
// Typed accessors parse the claim through the value object's IParsable — any backing primitive,
// here a Guid-backed tenant id (where: public sealed partial class TenantId : RequiredGuid<TenantId>;):
Result<TenantId> tenantVo = actor.GetRequiredAttribute<TenantId>(ActorAttributes.TenantId);
bool hasTenant = actor.TryGetAttribute<TenantId>(ActorAttributes.TenantId, out var typedTenant);
Cross-references
- trellis-api-asp.md —
Trellis.Asp.Authorizationactor providers (ClaimsActorProvider,EntraActorProvider,DevelopmentActorProvider,CachingActorProvider) and the matchingAddClaimsActorProvider/AddEntraActorProvider/AddDevelopmentActorProvider/AddCachingActorProvider<T>registration helpers. - trellis-api-mediator.md —
AuthorizationBehavior<TMessage, TResponse>pipeline behavior. - trellis-api-core.md —
Result,Error.Forbidden,Error.NotFound. - trellis-api-testing-aspnetcore.md —
WebApplicationFactoryExtensions.CreateClientWithActor(writes theX-Test-Actorheader consumed byDevelopmentActorProvider).