Authorization questions come in two shapes:
- Forward: "Can this subject perform this action on this specific resource?" —
ability.can(action, instance). - Reverse: "Which resources can this subject perform this action on?" —
accessibleBy(ability, action, type).
Both shapes apply the same rules but at different times in the request lifecycle. Picking the right one matters for both correctness and performance.
Forward checks
Used when you've already loaded a specific record and want to gate an action on it.
const merchant = await repo.findOneBy({ id });
if (!ability.can('update', merchant)) {
throw new ForbiddenException();
}
await repo.update({ id }, { status: 'active' });
Best for:
- Detail-page reads (
GET /merchants/:id) - Mutating actions (
POST /merchants/:id/approve) - Per-record audit checks inside a service
Performance: Constant time. The matcher walks the loaded instance against the rule's conditions in memory.
Caveat for $relatedTo: Forward checks need the relationship relations eager-loaded for $relatedTo rules to evaluate correctly. Without eager loading, the rule conservatively returns false. The example app's MerchantsService.findOne() falls back to a single-row EXISTS query when the eager load isn't available — see merchants.service.ts.
Reverse lookups
Used when you want a list of records the subject is allowed to see — without loading every row and filtering in memory.
const qb = repo.createQueryBuilder('m');
accessibleBy(ability, 'read', 'Merchant', { alias: 'm', graph }).applyTo(qb);
const merchants = await qb
.andWhere('m.status = :status', { status: 'active' })
.orderBy('m.name')
.take(50)
.getMany();
Best for:
- Listing endpoints (
GET /merchants) - Search and filter UIs
- Reports and dashboards
Performance: Single SQL query. Server-side LIMIT/ORDER BY work normally. The compiled WHERE includes:
- The tenant predicate (auto-injected at rule-build time).
- All
canrules ORed together. - All
cannotrules wrapped inNOT (...). - Multi-hop
$relatedTopaths asEXISTSsubqueries.
For an ISO admin with 10K merchants, the listing endpoint stays fast — the SQL is one bounded query, not 10K filter calls.
When to use which
| Scenario | Recommended shape |
|---|---|
GET /merchants (listing) | Reverse — accessibleBy() |
GET /merchants/:id (detail) | Forward — ability.can() after findOne |
POST /merchants/:id/approve | Forward — load + ability.can() |
| Sidebar widget showing accessible counts | Reverse — accessibleBy().getCount() |
| Service-level guard inside a multi-step transaction | Forward — load + check per record |
| GraphQL resolver returning a connection | Reverse — pagination depends on it |
They give the same answer
For any rule, forward check on instance X matches reverse-lookup inclusion of X:
ability.can(action, X) === (accessibleBy(...).getMany() includes X)
The library's E2E test suite (merchants-controller.e2e.test.ts in the example app) verifies this property — Alice's listing endpoint returns exactly the merchants for which ability.can('read', m) returns true, no more, no less. If you ever observe a divergence, that's a bug — please file an issue.
Anti-patterns
See also
- TypeORM integration —
accessibleBy()in detail. - Performance — query plans, indexes, and scale notes.