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.

The default forbidden message is a generic "Access denied." — it deliberately does not name the unmet permission/role/policy, so a forbidden caller cannot probe the permission vocabulary (the same opacity the feature-gate 404 applies). The default authorizer logs the unmet requirement at debug level for operators; a host that wants the detail on the wire sets AuthorizationOptions.ForbiddenMessageFormat to a format containing {0} (e.g. "Missing required permission: {0}").

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.

Deny-by-default does not implicitly attach to event-consumer handlers (a handler whose request implements IDomainEvent/IIntegrationEvent). Consumers are dispatched on a delivery scope with no authenticated user, so an implicit authenticated-by-default gate would fail every consumer. An explicit Require* on a consumer is a deliberate app choice and still attaches; only the implicit default is skipped.

Because Require*/[AllowAnonymous] are Inherited = true, a requirement declared on a base handler class also attaches the decorator to a derived handler (mirroring the runtime HandlerMetadata GetCustomAttributes(inherit: true) path), and an [AllowAnonymous] on the derived handler wins over an inherited base requirement.

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, 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 event consumers 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