Authorization
Declarative, transport-neutral handler authorization — claims, roles, permissions, and named policies — independent of the authentication provider.
Authorization in Elarion is declarative and transport-neutral. You annotate a handler with what it
requires; a generated decorator enforces it before the handler runs, under every transport (JSON-RPC,
MCP, HTTP) identically. It depends only on the claims of the current user — never on the authentication
provider or an HttpContext — so ASP.NET Core Identity, Entra ID, or any OIDC/JWT setup all work with
the same attributes.
Authentication vs authorization
- Authentication — who you are. The host's ASP.NET middleware validates a cookie, JWT, or OIDC
token and produces a
ClaimsPrincipal. This is the host's job; Elarion does not issue credentials. - Authorization — what you may do. Elarion's job, per handler.
The request flow:
authentication middleware → ICurrentUser snapshot → handler pipeline
└─ AuthorizationDecorator → IAuthorizer → handlerUseElarionCurrentUser() snapshots the authenticated principal's claims into the scoped
ICurrentUser; the generated authorization decorator reads the handler's
requirements and asks an IAuthorizer to evaluate them against that snapshot.
Declaring requirements
Annotate the handler class:
[Handler("tenants.create")]
[RequirePermission("tenants.write")]
public sealed class CreateTenant(AppDbContext db)
: IHandler<CreateTenant.Command, Result<CreateTenant.Response>> { /* ... */ }| Attribute | Requires |
|---|---|
[RequireClaim(type, values…)] | A claim of type. With values, at least one must match (OR); with none, the claim must merely be present. The general form. |
[RequirePermission("x")] | Sugar for [RequireClaim(PermissionClaimType, "x")] over the configured permission claim type (default "permission"). The common case. |
[RequireRole("Admin")] | Role membership (ICurrentUser.IsInRole). |
[RequirePolicy("AtLeast21")] | A named IAuthorizationPolicy — custom logic over the user and the request. |
[AllowAnonymous] | Nothing — the handler is public (see secure-by-default). |
Combining requirements
- Different attribute kinds combine with AND.
[RequirePolicy("AtLeast21")]+[RequireRole("Admin")]requires both. - Multiple of the same kind combine with AND. Two
[RequirePermission]requires both permissions. - OR lives inside a single
[RequireClaim]'s value list.[RequireClaim("scope", "read", "write")]passes if the user has either. [AllowAnonymous]wins over anyRequire*.
Authenticated, forbidden, unauthorized
A denied request short-circuits the pipeline with an AppError — the handler never runs, and neither
does caching, validation, or a transaction:
- An unauthenticated caller →
AppError.Unauthorized→ HTTP 401 / JSON-RPC-32005. - An authenticated but not permitted caller →
AppError.Forbidden→ HTTP 403 / JSON-RPC-32003.
This mirrors ASP.NET's 401-vs-403 split, mapped centrally — see Results & errors.
Opt-in by default
By default the authorization decorator is attached only to handlers that carry a Require* or
[RequirePolicy] attribute — apps that don't use authorization pay nothing. Attachment is decided at
compile time by the generator inspecting the handler, so an unguarded handler is genuinely unwrapped.
Secure-by-default
To flip to deny-by-default, declare [ElarionAuthorizationDefaults] at assembly or [AppModule] scope
(resolved most-specific-wins, like [DefaultPipeline]):
[assembly: ElarionAuthorizationDefaults] // RequireAuthenticated = trueNow every in-scope handler requires an authenticated principal unless it carries [AllowAnonymous].
A handler with explicit Require* attributes keeps those; an unannotated one just requires authentication.
The authorizer seam
IAuthorizer is the single decision point. The default ClaimsAuthorizer (registered by
AddElarionAuthorization()) evaluates every requirement against ICurrentUser and the registered
policies — no HttpContext, no ASP.NET dependency:
public interface IAuthorizer {
// null => authorized; AppError.Unauthorized/Forbidden otherwise.
ValueTask<AppError?> AuthorizeAsync(AuthorizationRequirements requirements, object? resource, CancellationToken ct);
}The resource is the handler request — a typed domain object, which is what named policies authorize
against (no digging route values out of an HttpContext).
Named policies
A named policy is a transport-neutral IAuthorizationPolicy. Mark it with [AuthorizationPolicy("name")] and
it is auto-registered per module (like [Service]) — no manual wiring, and the name is the compile-time
metadata [RequirePolicy("name")] references:
[AuthorizationPolicy("AtLeast21")]
public sealed class AtLeast21Policy(TimeProvider clock) : IAuthorizationPolicy {
public ValueTask<bool> EvaluateAsync(AuthorizationContext context, CancellationToken ct) {
var birthDate = context.User.GetClaimValues("birthdate").FirstOrDefault();
// compute age against clock.GetUtcNow() ...
return ValueTask.FromResult(/* age >= 21 */);
}
}The name lives on the attribute (or the registration call), never on the implementation — one source of truth.
A policy under an [AppModule] namespace is registered by that module's ConfigureDefaultServices; the generator
reports ELPOL001 if [AuthorizationPolicy] is on a type that isn't an IAuthorizationPolicy, and ELPOL002 if a
policy isn't under any module. For policies you'd rather wire by hand (or inline), register manually:
services.AddElarionAuthorization();
services.AddElarionAuthorizationPolicy<AtLeast21Policy>(); // reads the [AuthorizationPolicy] name
services.AddElarionAuthorizationPolicy("AtLeast21", (ctx, ct) => ...); // inline delegateProvider neutrality
Authorization depends only on ICurrentUser, so the same [Require*] handlers work under any
authentication provider. Two setups, identical handlers:
ASP.NET Core Identity
The optional Identity packages wire Identity onto a plain DbContext (no IdentityDbContext
inheritance) and map the current user to the Identity claim types — [GenerateElarionIdentity] (the
web-free model, in Elarion.EntityFrameworkCore.Identity) on the context, AddElarionIdentity (the host
wiring, in Elarion.AspNetCore.Identity) in Program.cs:
[GenerateDbSets]
[GenerateElarionIdentity<ApplicationUser, ApplicationRole, Guid>(SnakeCase = true)]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}
// Program.cs
builder.Services.AddElarionIdentity<ApplicationUser, ApplicationRole, Guid, AppDbContext>();
builder.Services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));
// cookie policy, AddAuthorization policies, UseAuthentication/UseElarionCurrentUser stay host-ownedThe generator emits the Identity DbSets (db.Users, db.Roles, …) and the snake_case model, so the
context never inherits IdentityDbContext — see the Identity capability.
Entra ID (no ASP.NET Identity)
Swap the authentication provider; the authorization is unchanged — and no Identity package:
builder.Services.AddAuthentication().AddMicrosoftIdentityWebApi(builder.Configuration); // or .AddJwtBearer
builder.Services.AddElarionCurrentUser(o => {
o.UserIdClaimType = "oid"; // Entra object id
o.RoleClaimType = "roles"; // Entra app roles
});
builder.Services.AddElarionAuthorization();The very same [RequireRole("Admin")] / [RequirePolicy("AtLeast21")] handlers are now enforced against
Entra's token claims — and would behave identically over JSON-RPC or MCP. That independence is the point:
authentication is a host concern, authorization is a handler concern, and they meet only at ICurrentUser.
Permission catalog
A handler declares the permission it needs as a (resource, verb) pair — the Kubernetes-RBAC shape —
[RequirePermission("properties", Verbs.Read)], enforced as the composed claim properties.read. No central
registry to edit. But the set of all permissions is still needed: to seed permission claims onto roles,
and to drive role→permission policy. Hand-maintaining that All/ReadOnly list is the one file that grows per
feature — the "edit-for-every-module" smell the generators otherwise eliminate.
The generator removes it. It discovers every [RequirePermission(resource, verb)]/[RequireRole] and emits two
surfaces, both keyed on the two Kubernetes axes — resource and verb.
ElarionPermissions — compile-time
A static class in your assembly's root namespace (above every [AppModule], so referencing it never trips
the ELMOD002 module-boundary analyzer), aggregated cross-assembly from the Elarion manifest. Reference it
from static role policy, which then reads like Kubernetes role rules:
// Roles.cs — no All/ReadOnly list to maintain
[Admin] = ElarionPermissions.All,
[PropertyAdmin] = ElarionPermissions.ByResource["properties"], // every verb on properties
[Viewer] = ElarionPermissions.ByVerb["read"], // read-only across everythingAll/Roles— the deduplicated, ordinally sorted universe.ByResource["properties"]— Kubernetes "all verbs on a resource".ByVerb["read"]— Kubernetes "this verb across all resources".ByModule["Properties"]— the per-[AppModule]view.- typed accessors —
ElarionPermissions.Properties.Read(resource → nested class, verb → const member).
The verb vocabulary is open (Kubernetes allows custom verbs too): use the Verbs constants
(Read/Write/List/Create/Update/Delete/Manage) or any string. The enforced claim is composed as
{resource}.{verb}, so your identity provider issues properties.read claims.
IPermissionCatalog — runtime
For code that enumerates permissions dynamically (a permissions admin screen), inject the DI catalog:
public sealed class IdentitySeeder(IPermissionCatalog catalog, RoleManager<AppRole> roles) {
public async Task SeedAdminAsync(AppRole admin) {
foreach (var permission in catalog.Permissions) // every permission across all modules
await roles.AddClaimAsync(admin, new Claim("permission", permission));
}
}It exposes the same Permissions/Roles/ByResource/ByVerb, plus a per-module Modules breakdown, populated
the same way services and validators are — each module contributes through its generated ConfigureDefaultServices,
gated by module enablement, so a disabled module's permissions never appear and a referenced module library's
are included automatically. AddElarionAuthorization registers it.
Generation is on under [assembly: UseElarion] (or the narrower [assembly: GeneratePermissionCatalog]); a
guarded handler under no module is reported (ELPERM001).
See also
- Resource authorization — per-resource (read/write) checks and efficient database-level filtering.
- Decorator pipelines — where the authorization decorator sits.
- Results & errors — how
Unauthorized/Forbiddenmap per transport. - Why Elarion → Composition over inheritance.
Decorator pipelines
Generated factories wrap each handler in an ordered decorator pipeline declared by assembly, module, or handler attributes.
Resource authorization
Per-resource (read/write) access control and efficient database-level filtering — owner, tenant, and role-based sharing — composed from one declarative source.