Elarion

Resource authorization

Per-resource (read/write) access control and efficient database-level filtering — owner, tenant, and role-based sharing — composed from one declarative source.

Handler authorization decides on the whole operation ("may this user call this handler?"). Resource authorization decides on specific resources ("may this user read this contact?", "which contacts may they list?"). These are two different problems, and Elarion solves them with two complementary, opt-in legs driven from one declarative source — never by filtering rows in memory after the query (which breaks pagination and leaks data).

Resource authorization is transport-neutral and depends only on ICurrentUser (user id + roles). The database is the only external system — there is no relationship/policy-engine dependency.

Two questions, two legs

QuestionLegMechanism
"Which resources may I read?" (lists/search)List filter[ResourceFilter<T>] → a predicate pushed into SQL before paging
"May I read/write this resource?" (get-one/write)Point check[RequireResource(...)] → the IResourceAuthorizer seam

Leg B — list filtering (push the rule into SQL)

Declare a filter for an entity on a dedicated partial class (the entity stays clean, like [Keyset<T>]):

[ResourceFilter<Contact>(
    OwnerProperty = nameof(Contact.OwnerId),     // a grant: the row's owner
    TenantProperty = nameof(Contact.TenantId),   // a scope: must match the caller's tenant claim
    Shared = true, ResourceTypeName = "Contact")] // a grant: shared via the grants table (user or role)
public sealed partial class ContactAccess;

The generator completes the class as an IQueryAuthorizer<Contact> and auto-registers it. Inject it into the handler and compose it into the query before paging:

public sealed class ListContacts(AppDbContext db, ICurrentUser user, IQueryAuthorizer<Contact> access)
    : IHandler<ListContacts.Query, Result<Page<ContactDto>>> {

    public async ValueTask<Result<Page<ContactDto>>> HandleAsync(Query request, CancellationToken ct) =>
        await db.Contacts
            .WhereAuthorized(access, user)                           // owner OR shared-with-user OR shared-with-role
            .Where(c => c.Category == request.Category)             // your business filters
            .ToKeysetPageAsync(request, RecentContacts.Definition, c => new ContactDto(c.Id), cancellationToken: ct);
}

The predicate is part of the single SQL statement, so the database filters the rows — pagination and total counts are correct, and unauthorized rows are never returned. The rules compose as AND(scopes) AND OR(grants): a row is visible when it satisfies every scope (tenant) and at least one grant (owner, or a share).

In plain terms, the Shared flag decides how wide the list is:

  • Shared = false (owner/tenant rules only) — the caller sees only the rows they own, within their tenant.
  • Shared = true — the caller also sees rows explicitly shared with them, either directly or with a role they belong to, recorded in the grants table.

Either way, a row the caller neither owns nor was granted is never returned — and never counted.

Inject the authorizer; the static Specification is a shortcut for field-only filters. A filter that uses Shared consults the grants table, so it is a scoped service — you must inject IQueryAuthorizer<Contact> (as above), and there is no static accessor. A filter with only field rules (OwnerProperty/TenantProperty) is stateless, so the generator also exposes a static ContactAccess.Specification singleton you can hand to WhereAuthorized without DI. Both forms are auto-registered as IQueryAuthorizer<Contact> by the host bootstrapper (module-feature-gated), so injection works for either.

Never filter a list in memory after the query (the @PostFilter shape). It returns short pages, wrong counts, and leaks how many rows exist. WhereAuthorized exists precisely so you don't.

WhereAuthorized is convention-only — nothing attaches it for you. Unlike [RequirePermission] or [RequireResource], which the generator auto-attaches as decorators, the list filter runs only where a handler composes it into the query. A list handler that forgets WhereAuthorized (or applies it after a .Select() projection that discards the entity) returns every row — the classic BOLA/IDOR shape. Treat "every list query over an access-controlled entity starts with WhereAuthorized" as a review checklist item, and cover each list handler with a test asserting an unshared row is absent.

This is the right trade for sharing, where the decision is per feature. It is the wrong trade for tenancy, where a forgotten call is a cross-tenant leak rather than a widened list — so tenancy gets its own unconditional mechanism. See multi-tenancy.

Administrators: a bypass permission

"Holds contacts.manage, sees every contact" is one property: BypassPermission. For a caller with that permission claim the owner and sharing rules are lifted; everyone else gets the declared rules. The scope stays: with a TenantProperty, the holder sees every contact of their own tenant, never another tenant's, and a caller without a tenant claim still sees nothing. Only a filter with no scope rule becomes unrestricted.

[ResourceFilter<Contact>(OwnerProperty = nameof(Contact.OwnerId), Shared = true, ResourceTypeName = "Contact",
    BypassPermission = "contacts.manage")]
public sealed partial class ContactAccess;

The permission is read from the permission claim, the default of AuthorizationOptions.PermissionClaimType; set PermissionClaimType on the attribute when the host changes it.

The bypass shapes the list filter. The default [RequireResource] point check reads only the grants table: it knows neither the owner column nor the bypass permission, so an administrator who sees a contact in the list is still denied by a get-one guarded with [RequireResource]. Where the filter defines visibility, check the single record by the same filter — db.Contacts.WhereAuthorized(access, currentUser).AnyAsync(c => c.Id == id, ct) in the handler (see the escape hatch) — so list and point check cannot drift.

Data that belongs to a resource

Notes, files and comments point at a resource by id and are visible exactly when that resource is. Filter them by their owner's visibility instead of re-implementing it for each table — WhereAuthorizedVia runs the owner's filter as a subquery of the same statement (WHERE contact_id IN (SELECT id FROM contacts WHERE …)):

var notes = await db.Notes
    .Where(n => n.OwnerKind == RecordKind.Contact)
    .WhereAuthorizedVia(n => n.OwnerId, db.Contacts, c => c.Id, contactAccess, currentUser)
    .ToOffsetPageAsync(request, n => new NoteRow(n.Id, n.Text), NoteSort, ct: ct);

Narrow a shared pointer to one owner kind first. When OwnerId points at contacts, properties and invoices alike, the OwnerKind filter above is mandatory, not an optimization: without it, a note on an invoice whose id equals a visible contact's id passes. For data whose owners are of several kinds, filter each kind's rows by its own owner and combine the queries (Concat) before paging. A nullable pointer works with an owner key of the same type (c => (Guid?)c.Id); rows pointing at no owner are then excluded.

Composing filters: membership tables and computed rules

A rule the attribute cannot express — sharing through an application-owned membership table (contact groups), a visibility computed elsewhere — is added to the generated filter, not written instead of it. QueryAuthorizers.AnyOf ORs authorizers (one unrestricted authorizer lifts the restriction), AllOf ANDs them, and From turns a delegate into one; the result is still a single predicate the database evaluates:

services.AddScoped<IQueryAuthorizer<Contact>>(sp => {
    var db = sp.GetRequiredService<AppDbContext>();
    return QueryAuthorizers.AnyOf(
        new ContactAccess(sp.GetRequiredService<IResourceGrantSource>()),   // owner, grants, bypass
        QueryAuthorizers.From<Contact>((user, _) => c => db.ContactGroupShares.Any(share =>
            share.ContactId == c.Id && db.GroupMembers.Any(m => m.GroupId == share.GroupId && m.UserId == user.UserId))));
});

Register the composed authorizer after the generated one: the generated filter registers itself as IQueryAuthorizer<Contact> when the module's services are added, and a single-service resolve takes the last registration. Registered before it, the composition is silently replaced by the bare generated filter. For anything else, write the IQueryAuthorizer<T> by hand — the logic is ordinary, testable C#, not a string expression.

Sharing with users and roles

Shares live in a DB-native grants table. Map it on your context — either with the attribute (emits the DbSet and the table mapping, like [GenerateElarionIdentity]):

[GenerateDbSets]
[GenerateElarionResourceGrants]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
    protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}

…or by hand: modelBuilder.ApplyElarionResourceGrants(). Both forms take the same overrides — the attribute accepts optional TableName and Schema properties alongside SnakeCase, and ApplyElarionResourceGrants(tableName, schema, snakeCase) mirrors them (defaults elarion_resource_grants, or ElarionResourceGrants with snakeCase: false; the principal index name is derived from the table name — ix_{table}_principal / IX_{table}_Principal). Then register the backend and manage shares:

services.AddElarionResourceAuthorization<AppDbContext>();   // grants source, store, and the point-check authorizer

Shares are managed through the injected IResourceGrantStore:

[Handler]
public sealed class ShareContact(IResourceGrantStore grants) {
    public async ValueTask<Result> HandleAsync(ShareContactCommand command, CancellationToken ct) {
        // Share one contact with the "Hausmeister" role for read — every user in that role can now see it.
        await grants.GrantAsync(
            new ResourceGrant("Contact", command.ContactId.ToString(),
                ResourcePrincipal.Role("Hausmeister"), ResourceOperation.Read), ct);
        return Result.Success();
    }
}

The Shared filter turns this into an indexed correlated EXISTS over the caller's user id or any of their roles — a small, bounded lookup that scales.

Granting the same (resource, principal, operation) twice is idempotent — the EF Core backend claims a grant with an INSERT … ON CONFLICT DO NOTHING, so a concurrent duplicate never raises a unique violation (which would otherwise abort, and poison, the caller's ambient transaction). The resource-type discriminator, resource id, user ids, and role names are matched case-sensitively (StringComparison.Ordinal): a casing mismatch between a stored grant and the caller's ICurrentUser fails closed (denies) — it never grants access. Store principal ids and role names in a consistent casing.

Grants are operation-scoped, and the Shared rule only matches grants for the operation WhereAuthorized asks about — which defaults to Read. To list the rows a caller may edit, pass the operation explicitly:

db.Contacts.WhereAuthorized(access, user, ResourceOperation.Update)   // EXISTS matches "update" grants

The operation narrows only the grant (Shared) lookup; the OwnerProperty/TenantProperty rules are operation-independent, so an owner still sees their own rows under any operation.

Leg A — the per-resource point check

For a get-one or write handler, declare which request property names the resource. The id is a compile-checked path (per ADR-0012), not a string expression — the generator validates it against the request and emits a zero-reflection accessor:

[RequireResource(typeof(Contact), Operation = "read", Id = nameof(GetContact.Query.Id))]
public sealed class GetContact : IHandler<GetContact.Query, Result<ContactDto>> {
    public sealed record Query : IQuery { public Guid Id { get; init; } }
    // ...
}

The decorator resolves the id and asks IResourceAuthorizer. The shipped default authorizes from the grants table (a share with the caller's user or roles); with no backend registered it fails closed (a logged 403).

Hidden, not forbidden

A 403 tells the caller the record exists. When a list filter hides records, their point check should answer like a missing record instead — otherwise ids can be probed one by one:

[RequireResource(typeof(Contact), Id = nameof(GetContact.Query.Id), DenyAs = ResourceDenial.NotFound)]
public sealed class GetContact : IHandler<GetContact.Query, Result<ContactDto>> { /* ... */ }

A denial now answers 404 with the code not_found and AuthorizationOptions.ResourceNotFoundMessage (default "The requested resource was not found."); make the handler's own missing-record answer use the same message, or the two stay distinguishable. not_found joins the operation's declared error contract. With several [RequireResource] on one handler, they are checked in declaration order and the first denial answers with its own DenyAs, so declare the hidden resource first when a later, forbidden one would confirm it exists.

The resource-type discriminator

A grant identifies its resource by a discriminator string, stored in the resource_type column. The point check derives that discriminator from typeof(Contact) — by default the type's FullName (e.g. MyApp.Crm.Contact), so two entities named Contact in different modules never share authorization. Every path that produces or compares the discriminator agrees on it via Elarion.Abstractions.Authorization.ResourceTypeDiscriminator:

  • what IResourceGrantStore.GrantAsync writes (the ResourceGrant.ResourceType string you pass),
  • the point check in the grants-backed IResourceAuthorizer, and
  • the EXISTS subquery the [ResourceFilter(Shared = true)] generator emits.

When you want a stable, namespace-independent discriminator (e.g. the short "Contact"), set the same override on all three: [RequireResource(typeof(Contact), ResourceTypeName = "Contact")], [ResourceFilter<Contact>(Shared = true, ResourceTypeName = "Contact")], and the ResourceType string on every ResourceGrant you write. If they disagree, the grant simply never matches (fail-closed).

The escape hatch — validate before a write

Inject IResourceAuthorizer (or compose the injected IQueryAuthorizer<T> with WhereAuthorized) to run your own check in a handler before writing — this is the deliberate alternative to a hidden write interceptor:

// grant-aware check by id (an EXISTS — no full load)
if (!await resourceAuth.AuthorizeResourceAsync(
        new ResourceAuthorizationContext(currentUser, typeof(Contact), "Contact", ResourceOperation.Update, request.Id), ct))
    return Result.Failure(AppError.Forbidden("Not allowed to update this contact."));

// or reuse the filter's own predicate for the operation
var allowed = await db.Contacts
    .WhereAuthorized(access, currentUser, ResourceOperation.Update)
    .AnyAsync(c => c.Id == request.Id, ct);
if (!allowed)
    return Result.Failure(AppError.Forbidden("Not allowed to update this contact."));

[ResourceFilter] predicates are a read filter; they do not stop a wrong-owner write. Enforce writes with [RequireResource] or the escape hatch above. Raw SQL (FromSqlRaw) and IgnoreQueryFilters bypass the in-app predicate — there is no Postgres RLS backstop, so keep enforcement in your handlers.

Who may see a record?

Mentions and notifications ask the inverse question: which principals may see record X. Elarion answers the part it owns — IResourceGrantStore.GetGrantsAsync(resourceType, resourceId) lists the users and roles a record is shared with — while the owner column, tenant membership, bypass-permission holders and application membership tables are the application's data. Compose the answer from the same rules the filter declares, so the two cannot drift: the grants for X, plus X's owner, intersected with the active users of X's tenant.

See also

On this page