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

  • 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 → 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", Verbs.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", Verbs.Read)]Sugar for [RequireClaim(PermissionClaimType, "x.read")] — the composed {resource}.{verb} claim 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

Global rules — one condition across every handler

A named policy answers "does this handler's declared requirement hold". Some conditions are not per-handler at all: a suspended tenant, a lapsed subscription, a read-only maintenance window. Register an IGlobalAuthorizationRule and it runs for every authorized invocation — after the authenticated gate, before the declared permission/role/claim/policy/resource checks:

public sealed class ActiveSubscriptionRule(ITenantContext tenant, ITenantDirectory directory) : IGlobalAuthorizationRule {
    public async ValueTask<AppError?> EvaluateAsync(AuthorizationContext context, CancellationToken ct) {
        // ITenantDirectory is app-owned; ITenantContext only carries the ambient TenantId.
        var suspended = tenant.TenantId is { } id && await directory.IsSuspendedAsync(id, ct);
        return suspended ? AppError.Forbidden("This workspace is suspended.") : null;   // null => pass
    }
}

builder.Services.AddElarionGlobalAuthorizationRule<ActiveSubscriptionRule>();

Rules are additive and run in registration order; the first non-null AppError wins and reaches the caller unchanged, so the rule — not the framework — chooses the outcome kind. A rule that must not disclose a resource returns AppError.NotFound(...); one that states the denial plainly returns AppError.Forbidden(...). The AuthorizationContext carries the current user and the handler request, so a rule can scope itself to a request shape (e.g. only writes) without touching the handler. On the client-event subscription path there is no request, so context.Resource is null — a rule that branches on the request shape will fall through to its denial there, which is fail-closed but may not be what you meant. Handle null explicitly if a rule should treat subscribes differently from commands.

A rule only runs where the authorization decorator is attached, and the generator attaches it at compile time only for handlers carrying a Require* attribute or in scope of [ElarionAuthorizationDefaults]. For broad coverage, pair the rule with the assembly-level default:

[assembly: ElarionAuthorizationDefaults] // RequireAuthenticated = true

That reaches every handler except event-consumer handlers, which the deny-by-default section above exempts from implicit attachment (they run on a delivery scope with no authenticated user). A consumer carrying an explicit Require* still gets the decorator — and is then rejected as unauthenticated on that scope, before any rule is consulted.

Rules always run behind the authentication gate: a handler that declares any requirement rejects an unauthenticated caller with Unauthorized before rules are reached, so a rule never has to defend against a missing principal. [AllowAnonymous] handlers opt out of authorization entirely, global rules included — that attribute means "this operation is outside authorization", and a rule must not resurrect a gate the handler declined. A check that must also cover anonymous traffic belongs in the handler or in host middleware.

Client-event subscriptions are covered: any topic declaring a requirement is decided by the same IAuthorizer, so a rule that denies a request also denies the corresponding subscribe.

Replacing or decorating the authorizer

Every registration in AddElarionAuthorization() uses TryAdd, and the concrete ClaimsAuthorizer is registered as its own service with the IAuthorizer registration resolving it. That pins two seams:

  • Replace — register your own IAuthorizer before calling AddElarionAuthorization(); the framework then adds nothing for that service.
  • Decorate — do the same, and inject the concrete ClaimsAuthorizer as your inner authorizer. Both resolve to the same scoped instance, so there is no second authorizer and no RemoveAll gymnastics:
public sealed class AuditingAuthorizer(ClaimsAuthorizer inner, IAuditSink sink) : IAuthorizer {
    public async ValueTask<AppError?> AuthorizeAsync(
        AuthorizationRequirements requirements, object? resource, CancellationToken ct) {
        var error = await inner.AuthorizeAsync(requirements, resource, ct);
        if (error is not null) await sink.RecordDenialAsync(error, ct);
        return error;
    }
}

builder.Services.AddScoped<IAuthorizer, AuditingAuthorizer>();   // registered first — TryAdd defers to it
builder.Services.AddElarionAuthorization();                      // still registers ClaimsAuthorizer itself

Reach for a global rule when the condition is a decision; reach for decoration when you need to observe or reshape decisions the shipped authorizer already makes.

Only ClaimsAuthorizer evaluates IGlobalAuthorizationRule. Wholesale replacement of IAuthorizer makes every registered rule inert unless the replacement consults them itself — decoration keeps them running, because the inner ClaimsAuthorizer still does the work.

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