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
| Question | Leg | Mechanism |
|---|---|---|
| "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 authorizerShares 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" grantsThe 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.GrantAsyncwrites (theResourceGrant.ResourceTypestring you pass), - the point check in the grants-backed
IResourceAuthorizer, and - the
EXISTSsubquery 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
- ADR-0013: Resource-based and data-level authorization
- ADR-0012: Referencing dynamic variables in attributes
- Authorization — the operation-level gate this builds on.
Authorization
Declarative, transport-neutral handler authorization — claims, roles, permissions, and named policies — independent of the authentication provider.
Feature flags & variants
Every feature flag is declared once with exactly one owner — a resolver class or the host's backend — and gates, the client snapshot, and variants all evaluate through the same catalog.