Elarion

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

  • Authenticationwho 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.
  • Authorizationwhat you may do. Elarion's job, per handler.

The request flow:

authentication middleware  →  ICurrentUser snapshot  →  handler pipeline
                                                          └─ AuthorizationDecorator → IAuthorizer → handler

UseElarionCurrentUser() 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>> { /* ... */ }
AttributeRequires
[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 any Require*.

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 = true

Now 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 delegate

Provider 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-owned

The 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 everything
  • All / 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

On this page